bitwrench 1.2.15 → 2.0.7
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.
- package/README.md +160 -158
- package/bin/bitwrench.js +3 -0
- package/dist/bitwrench-code-edit.cjs.js +639 -0
- package/dist/bitwrench-code-edit.es5.js +875 -0
- package/dist/bitwrench-code-edit.es5.min.js +15 -0
- package/dist/bitwrench-code-edit.esm.js +628 -0
- package/dist/bitwrench-code-edit.esm.min.js +15 -0
- package/dist/bitwrench-code-edit.umd.js +645 -0
- package/dist/bitwrench-code-edit.umd.min.js +15 -0
- package/dist/bitwrench.cjs.js +6983 -0
- package/dist/bitwrench.cjs.min.js +62 -0
- package/dist/bitwrench.css +5100 -0
- package/dist/bitwrench.es5.js +8446 -0
- package/dist/bitwrench.es5.min.js +31 -0
- package/dist/bitwrench.esm.js +6981 -0
- package/dist/bitwrench.esm.min.js +62 -0
- package/dist/bitwrench.umd.js +6989 -0
- package/dist/bitwrench.umd.min.js +62 -0
- package/dist/builds.json +127 -0
- package/dist/sri.json +18 -0
- package/package.json +86 -24
- package/readme.html +288 -0
- package/src/bitwrench-code-edit.js +627 -0
- package/src/bitwrench-color-utils.js +311 -0
- package/src/bitwrench-component-base.js +736 -0
- package/src/bitwrench-components-inline.js +374 -0
- package/src/bitwrench-components-v2.js +1879 -0
- package/src/bitwrench-components.js +610 -0
- package/src/bitwrench-styles.js +3240 -0
- package/src/bitwrench.js +3367 -0
- package/src/cli/convert.js +205 -0
- package/src/cli/index.js +122 -0
- package/src/cli/inject.js +55 -0
- package/src/cli/layout-default.js +142 -0
- package/src/generate-css.js +381 -0
- package/src/vendor/quikdown.js +654 -0
- package/src/version.js +16 -0
- package/.eslintrc.json +0 -27
- package/.github/workflows/codeql-analysis.yml +0 -72
- package/.travis.yml +0 -34
- package/bitwrench.css +0 -92
- package/bitwrench.js +0 -3348
- package/bitwrench.js_sri.txt +0 -1
- package/bitwrench.min.js +0 -1
- package/bitwrench.min.js_sri.txt +0 -1
- package/bitwrench_ESM.js +0 -3207
- package/dev/bitwrench-todo.md +0 -215
- package/dev/css-arrows.md +0 -23
- package/dev/docStringDev.js +0 -124
- package/dev/docStringParseDev.js +0 -171
- package/dev/figures.html +0 -37
- package/dev/html_gen.js +0 -349
- package/dev/htmld.md +0 -250
- package/dev/htmldev.html +0 -45
- package/dev/index-old.html +0 -87
- package/dev/misc-notes.md +0 -21
- package/dev/notes.md +0 -2
- package/dev/sizes.html +0 -49
- package/dev/universal-js-module.js +0 -37
- package/examples/example1.html +0 -78
- package/examples/example10.html +0 -84
- package/examples/example2.html +0 -44
- package/examples/example3.html +0 -50
- package/examples/example4.html +0 -22
- package/examples/example5.html +0 -82
- package/examples/example6.html +0 -128
- package/examples/example7.html +0 -91
- package/examples/example8.html +0 -27
- package/examples/example9.html +0 -102
- package/icon/bitwrench-dark-tall.png +0 -0
- package/icon/bitwrench-dark.png +0 -0
- package/icon/bitwrench-icon-lt-grey.png +0 -0
- package/icon/bitwrench-icon.vsd +0 -0
- package/icon/bitwrench-logo-dark.png +0 -0
- package/icon/bitwrench-logo-full.png +0 -0
- package/icon/bitwrench-logo-green.png +0 -0
- package/icon/bitwrench-logo-grey.png +0 -0
- package/icon/bitwrench-logo-white.png +0 -0
- package/icon/bitwrench-logos-colors.png +0 -0
- package/icon/bitwrench-thick-logo.png +0 -0
- package/icon/bitwrench-thick-teal/android-chrome-192x192.png +0 -0
- package/icon/bitwrench-thick-teal/android-chrome-512x512.png +0 -0
- package/icon/bitwrench-thick-teal/apple-touch-icon.png +0 -0
- package/icon/bitwrench-thick-teal/browserconfig.xml +0 -9
- package/icon/bitwrench-thick-teal/favicon-16x16.png +0 -0
- package/icon/bitwrench-thick-teal/favicon-32x32.png +0 -0
- package/icon/bitwrench-thick-teal/favicon.ico +0 -0
- package/icon/bitwrench-thick-teal/mstile-144x144.png +0 -0
- package/icon/bitwrench-thick-teal/mstile-150x150.png +0 -0
- package/icon/bitwrench-thick-teal/mstile-310x150.png +0 -0
- package/icon/bitwrench-thick-teal/mstile-310x310.png +0 -0
- package/icon/bitwrench-thick-teal/mstile-70x70.png +0 -0
- package/icon/bitwrench-thick-teal/site.webmanifest +0 -19
- package/icon/bitwrench-thick-teal.ico +0 -0
- package/icon/bitwrench-thick-teal.svg +0 -44
- package/icon/bitwrench-thick-teal.zip +0 -0
- package/icon/favicon-test.html +0 -20
- package/icon/logos-test.PNG +0 -0
- package/images/bitwrench-512x512.png +0 -0
- package/images/bitwrench-logo-med.png +0 -0
- package/images/bitwrench-thick-logo.png +0 -0
- package/images/bitwrench-thick-logo.svg +0 -64
- package/images/bitwrench-thick-teal.ico +0 -0
- package/images/favicon.ico +0 -0
- package/index.html +0 -256
- package/instr_tmp/bitwrench.js +0 -1350
- package/karma.conf.js +0 -140
- package/makefile +0 -21
- package/quick-docs.html +0 -206
- package/test/bitwrench_test.js +0 -1255
- package/test/karma-test.js +0 -1081
- package/tools/bw_deprecatedNames.js +0 -19
- package/tools/bwconsole.js +0 -20
- package/tools/createSimpleHTMLPage.js +0 -41
- package/tools/emitreadme.sh +0 -4
- package/tools/export-bw-default-css.js +0 -41
- package/tools/umd2ModuleHack.js +0 -32
- package/tools/update-bw-package.js +0 -36
- package/tools/updatereadme.js +0 -34
package/src/bitwrench.js
ADDED
|
@@ -0,0 +1,3367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitwrench v2 Core
|
|
3
|
+
* Zero-dependency UI library using JavaScript objects
|
|
4
|
+
* Works in browsers (IE11+) and Node.js
|
|
5
|
+
*
|
|
6
|
+
* @license BSD-2-Clause
|
|
7
|
+
* @author M A Chatterjee <deftio [at] deftio [dot] com>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { VERSION_INFO } from './version.js';
|
|
11
|
+
import { getStructuralStyles, theme, updateTheme, generateDarkModeCSS,
|
|
12
|
+
generateThemedCSS, derivePalette as _derivePalette,
|
|
13
|
+
DEFAULT_PALETTE_CONFIG, SPACING_PRESETS, RADIUS_PRESETS, THEME_PRESETS,
|
|
14
|
+
resolveLayout, addUnderscoreAliases } from './bitwrench-styles.js';
|
|
15
|
+
import { hexToHsl, hslToHex, adjustLightness, mixColor,
|
|
16
|
+
relativeLuminance, textOnColor, deriveShades,
|
|
17
|
+
derivePalette } from './bitwrench-color-utils.js';
|
|
18
|
+
|
|
19
|
+
// Environment-aware module loader for optional Node.js built-ins (fs).
|
|
20
|
+
// Strategy: try require() first (CJS/UMD), fall back to import() (ESM).
|
|
21
|
+
// import() is wrapped in Function() to avoid parse errors in ES5/IE11 environments.
|
|
22
|
+
|
|
23
|
+
// Core bitwrench namespace
|
|
24
|
+
const bw = {
|
|
25
|
+
// Version info from generated file
|
|
26
|
+
version: VERSION_INFO.version,
|
|
27
|
+
versionInfo: VERSION_INFO,
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get version metadata object (v1-compatible callable API).
|
|
31
|
+
*
|
|
32
|
+
* Returns a copy of the build-time version info including version string,
|
|
33
|
+
* name, build date, and git hash.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Object} Copy of VERSION_INFO with version, name, buildDate, etc.
|
|
36
|
+
* @category Core
|
|
37
|
+
*/
|
|
38
|
+
getVersion: function() {
|
|
39
|
+
return { ...VERSION_INFO };
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Internal state
|
|
43
|
+
_idCounter: 0,
|
|
44
|
+
_unmountCallbacks: new Map(),
|
|
45
|
+
_topics: {}, // topic → [{handler, id}] (plain object for IE11 compat)
|
|
46
|
+
_subIdCounter: 0, // monotonic ID for subscriptions
|
|
47
|
+
|
|
48
|
+
// Monkey patch for testing (same as v1)
|
|
49
|
+
__monkey_patch_is_nodejs__: {
|
|
50
|
+
_value: 'ignore',
|
|
51
|
+
set: function(x) {
|
|
52
|
+
this._value = (typeof x === 'boolean') ? x : 'ignore';
|
|
53
|
+
},
|
|
54
|
+
get: function() {
|
|
55
|
+
return this._value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect if running in Node.js environment.
|
|
62
|
+
*
|
|
63
|
+
* Useful for writing isomorphic code that behaves differently in Node.js vs browser.
|
|
64
|
+
* Uses `process.versions.node` for reliable detection that works in both CJS and ESM.
|
|
65
|
+
*
|
|
66
|
+
* @returns {boolean} True if Node.js, false if browser
|
|
67
|
+
* @category Core
|
|
68
|
+
* @example
|
|
69
|
+
* if (bw.isNodeJS()) {
|
|
70
|
+
* console.log('Running in Node.js');
|
|
71
|
+
* } else {
|
|
72
|
+
* console.log('Running in browser');
|
|
73
|
+
* }
|
|
74
|
+
*/
|
|
75
|
+
bw.isNodeJS = function() {
|
|
76
|
+
// Check monkey patch first (for testing)
|
|
77
|
+
if (bw.__monkey_patch_is_nodejs__.get() !== 'ignore') {
|
|
78
|
+
return bw.__monkey_patch_is_nodejs__.get();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Reliable Node.js detection: works in both CJS and ESM
|
|
82
|
+
// - `process.versions.node` exists in Node.js but not browsers
|
|
83
|
+
// - `typeof window` alone is unreliable (jsdom, Electron, Deno)
|
|
84
|
+
return typeof process !== 'undefined'
|
|
85
|
+
&& process.versions != null
|
|
86
|
+
&& process.versions.node != null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Set runtime flags based on detection
|
|
90
|
+
// _isNode: Node.js APIs (fs, process) available — static, won't change at runtime
|
|
91
|
+
// _isBrowser: DOM APIs (document, window) available — dynamic getter because
|
|
92
|
+
// globals may be set up after module init (e.g., jsdom in test environments)
|
|
93
|
+
// These are NOT mutually exclusive: jsdom provides DOM in Node.js
|
|
94
|
+
bw._isNode = bw.isNodeJS();
|
|
95
|
+
Object.defineProperty(bw, '_isBrowser', {
|
|
96
|
+
get: function() { return typeof document !== 'undefined' && typeof window !== 'undefined'; },
|
|
97
|
+
configurable: true
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Lazy-resolve Node.js `fs` module.
|
|
102
|
+
* Tries require('fs') first (available in CJS/UMD Node.js builds),
|
|
103
|
+
* then falls back to dynamic import('fs') for ESM.
|
|
104
|
+
* The import() call is wrapped in Function() so ES5 parsers (IE11) don't
|
|
105
|
+
* choke on the syntax — it's only evaluated at runtime in Node.js.
|
|
106
|
+
* Returns a Promise resolving to the fs module or null in browsers.
|
|
107
|
+
* Result is cached after first resolution.
|
|
108
|
+
* @private
|
|
109
|
+
* @returns {Promise<Object|null>} - Promise resolving to Node fs module or null
|
|
110
|
+
*/
|
|
111
|
+
bw._fsCache = undefined; // undefined = not yet resolved, null = resolved but unavailable
|
|
112
|
+
bw._getFs = function() {
|
|
113
|
+
if (bw._fsCache !== undefined) return Promise.resolve(bw._fsCache);
|
|
114
|
+
if (!bw.isNodeJS()) { bw._fsCache = null; return Promise.resolve(null); }
|
|
115
|
+
|
|
116
|
+
// Strategy 1: synchronous require (CJS / UMD in Node.js)
|
|
117
|
+
if (typeof require === 'function') {
|
|
118
|
+
try {
|
|
119
|
+
bw._fsCache = require('fs');
|
|
120
|
+
return Promise.resolve(bw._fsCache);
|
|
121
|
+
} catch(e) { /* require not available or failed, try import */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Strategy 2: dynamic import (ESM in Node.js)
|
|
125
|
+
// Wrapped in Function() so the import() keyword isn't parsed by ES5 engines
|
|
126
|
+
try {
|
|
127
|
+
var _importDynamic = new Function('m', 'return import(m)');
|
|
128
|
+
return _importDynamic('fs').then(function(mod) {
|
|
129
|
+
bw._fsCache = mod.default || mod;
|
|
130
|
+
return bw._fsCache;
|
|
131
|
+
}).catch(function() {
|
|
132
|
+
bw._fsCache = null;
|
|
133
|
+
return null;
|
|
134
|
+
});
|
|
135
|
+
} catch(e) {
|
|
136
|
+
// Function() construction failed (shouldn't happen, but safety net)
|
|
137
|
+
bw._fsCache = null;
|
|
138
|
+
return Promise.resolve(null);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Enhanced type detection that distinguishes arrays, dates, regexps, and more.
|
|
144
|
+
*
|
|
145
|
+
* Goes beyond `typeof` by using `Object.prototype.toString` to identify
|
|
146
|
+
* specific object types. Returns lowercase strings for primitives and arrays,
|
|
147
|
+
* PascalCase for built-in classes (Date, RegExp, Map, Set, etc.).
|
|
148
|
+
*
|
|
149
|
+
* @param {*} x - Value to examine
|
|
150
|
+
* @param {boolean} [baseTypeOnly=false] - If true, return only the base type ("object" for all objects)
|
|
151
|
+
* @returns {string} Type name as shown in table below
|
|
152
|
+
* @category Core
|
|
153
|
+
* @example
|
|
154
|
+
* // Primitives (lowercase):
|
|
155
|
+
* bw.typeOf("hello") // => "string"
|
|
156
|
+
* bw.typeOf(42) // => "number"
|
|
157
|
+
* bw.typeOf(true) // => "boolean"
|
|
158
|
+
* bw.typeOf(undefined) // => "undefined"
|
|
159
|
+
* bw.typeOf(null) // => "null"
|
|
160
|
+
* bw.typeOf(Symbol('x')) // => "symbol"
|
|
161
|
+
* bw.typeOf(42n) // => "bigint"
|
|
162
|
+
* bw.typeOf(() => {}) // => "function"
|
|
163
|
+
*
|
|
164
|
+
* // Arrays (lowercase):
|
|
165
|
+
* bw.typeOf([1, 2, 3]) // => "array"
|
|
166
|
+
*
|
|
167
|
+
* // Built-in classes (PascalCase):
|
|
168
|
+
* bw.typeOf(new Date()) // => "Date"
|
|
169
|
+
* bw.typeOf(/abc/) // => "RegExp"
|
|
170
|
+
* bw.typeOf(new Error()) // => "Error"
|
|
171
|
+
* bw.typeOf(new Map()) // => "Map"
|
|
172
|
+
* bw.typeOf(new Set()) // => "Set"
|
|
173
|
+
* bw.typeOf(new WeakMap()) // => "WeakMap"
|
|
174
|
+
* bw.typeOf(new WeakSet()) // => "WeakSet"
|
|
175
|
+
* bw.typeOf(Promise.resolve()) // => "Promise"
|
|
176
|
+
*
|
|
177
|
+
* // Typed arrays (PascalCase):
|
|
178
|
+
* bw.typeOf(new Uint8Array()) // => "Uint8Array"
|
|
179
|
+
* bw.typeOf(new Float64Array()) // => "Float64Array"
|
|
180
|
+
* bw.typeOf(new ArrayBuffer(8)) // => "ArrayBuffer"
|
|
181
|
+
*
|
|
182
|
+
* // Plain objects and custom classes:
|
|
183
|
+
* bw.typeOf({a: 1}) // => "Object"
|
|
184
|
+
* bw.typeOf(new MyClass()) // => "MyClass" (constructor.name)
|
|
185
|
+
*
|
|
186
|
+
* // baseTypeOnly mode:
|
|
187
|
+
* bw.typeOf([1,2], true) // => "object"
|
|
188
|
+
*/
|
|
189
|
+
bw.typeOf = function(x, baseTypeOnly) {
|
|
190
|
+
if (x === null) return "null";
|
|
191
|
+
|
|
192
|
+
const basic = typeof x;
|
|
193
|
+
|
|
194
|
+
if (basic !== "object") {
|
|
195
|
+
return basic; // covers: string, number, boolean, undefined, function, symbol, bigint
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (baseTypeOnly) return basic;
|
|
199
|
+
|
|
200
|
+
const stringTag = Object.prototype.toString.call(x);
|
|
201
|
+
|
|
202
|
+
const typeMap = {
|
|
203
|
+
'[object Array]': 'array',
|
|
204
|
+
'[object Date]': 'Date',
|
|
205
|
+
'[object RegExp]': 'RegExp',
|
|
206
|
+
'[object Error]': 'Error',
|
|
207
|
+
'[object Promise]': 'Promise',
|
|
208
|
+
'[object Map]': 'Map',
|
|
209
|
+
'[object Set]': 'Set',
|
|
210
|
+
'[object WeakMap]': 'WeakMap',
|
|
211
|
+
'[object WeakSet]': 'WeakSet',
|
|
212
|
+
'[object ArrayBuffer]': 'ArrayBuffer',
|
|
213
|
+
'[object DataView]': 'DataView',
|
|
214
|
+
'[object Int8Array]': 'Int8Array',
|
|
215
|
+
'[object Uint8Array]': 'Uint8Array',
|
|
216
|
+
'[object Uint8ClampedArray]': 'Uint8ClampedArray',
|
|
217
|
+
'[object Int16Array]': 'Int16Array',
|
|
218
|
+
'[object Uint16Array]': 'Uint16Array',
|
|
219
|
+
'[object Int32Array]': 'Int32Array',
|
|
220
|
+
'[object Uint32Array]': 'Uint32Array',
|
|
221
|
+
'[object Float32Array]': 'Float32Array',
|
|
222
|
+
'[object Float64Array]': 'Float64Array'
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (typeMap[stringTag]) {
|
|
226
|
+
return typeMap[stringTag];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check for custom bitwrench types
|
|
230
|
+
if (x._bw_type) {
|
|
231
|
+
return x._bw_type;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Try constructor name
|
|
235
|
+
if (x.constructor && x.constructor.name) {
|
|
236
|
+
return x.constructor.name;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return basic;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Alias
|
|
243
|
+
bw.to = bw.typeOf;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generate a unique identifier string for DOM elements or application use.
|
|
247
|
+
*
|
|
248
|
+
* Uses `crypto.randomUUID()` when available (modern browsers), otherwise
|
|
249
|
+
* falls back to a timestamp + counter + random combination. Optional prefix
|
|
250
|
+
* creates namespaced IDs like `bw_card_<hex>` for easier debugging.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} [prefix] - Optional namespace prefix (e.g. "card", "todo")
|
|
253
|
+
* @returns {string} Unique identifier (e.g. "bw_card_a1b2c3d4")
|
|
254
|
+
* @category Identifiers
|
|
255
|
+
* @example
|
|
256
|
+
* bw.uuid() // => "bw_m3x9k_1_7f2h4j6a8"
|
|
257
|
+
* bw.uuid('card') // => "bw_card_a1b2c3d4e5f6"
|
|
258
|
+
*/
|
|
259
|
+
bw.uuid = function(prefix) {
|
|
260
|
+
// Optional prefix creates IDs like bw_card_<hex>, bw_todo_<hex>, etc.
|
|
261
|
+
// Without prefix: bw_<hex>
|
|
262
|
+
var tag = prefix ? 'bw_' + prefix + '_' : 'bw_';
|
|
263
|
+
|
|
264
|
+
// Use crypto.randomUUID if available (modern browsers)
|
|
265
|
+
if (bw._isBrowser && crypto && crypto.randomUUID) {
|
|
266
|
+
return tag + crypto.randomUUID().replace(/-/g, '');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fallback for older browsers and Node.js
|
|
270
|
+
const timestamp = Date.now().toString(36);
|
|
271
|
+
const counter = (++bw._idCounter).toString(36);
|
|
272
|
+
const random = Math.random().toString(36).substring(2, 11);
|
|
273
|
+
|
|
274
|
+
return `${tag}${timestamp}_${counter}_${random}`;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Escape HTML special characters to prevent XSS.
|
|
279
|
+
*
|
|
280
|
+
* Converts &, <, >, ", ', and / to their HTML entity equivalents.
|
|
281
|
+
* Used automatically by `bw.html()` unless raw mode is enabled.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} str - String to escape
|
|
284
|
+
* @returns {string} Escaped string safe for HTML insertion
|
|
285
|
+
* @category Identifiers
|
|
286
|
+
* @see bw.html
|
|
287
|
+
* @example
|
|
288
|
+
* bw.escapeHTML('<b>Hello</b> & "world"')
|
|
289
|
+
* // => '<b>Hello</b> & "world"'
|
|
290
|
+
*/
|
|
291
|
+
bw.escapeHTML = function(str) {
|
|
292
|
+
if (typeof str !== 'string') return '';
|
|
293
|
+
|
|
294
|
+
const escapeMap = {
|
|
295
|
+
'&': '&',
|
|
296
|
+
'<': '<',
|
|
297
|
+
'>': '>',
|
|
298
|
+
'"': '"',
|
|
299
|
+
"'": ''',
|
|
300
|
+
'/': '/'
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return str.replace(/[&<>"'/]/g, (char) => escapeMap[char]);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Normalize CSS class names by converting underscores to hyphens for bw-prefixed classes.
|
|
308
|
+
*
|
|
309
|
+
* Allows users to write either `bw_card` or `bw-card` and get consistent
|
|
310
|
+
* hyphenated output. Only converts the `bw_` prefix — other underscores are untouched.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} classStr - Class string to normalize
|
|
313
|
+
* @returns {string} Normalized class string with hyphens
|
|
314
|
+
* @category Identifiers
|
|
315
|
+
* @example
|
|
316
|
+
* bw.normalizeClass('bw_card bw_btn') // => 'bw-card bw-btn'
|
|
317
|
+
* bw.normalizeClass('my_class') // => 'my_class' (unchanged)
|
|
318
|
+
*/
|
|
319
|
+
bw.normalizeClass = function(classStr) {
|
|
320
|
+
if (typeof classStr !== 'string') return classStr;
|
|
321
|
+
return classStr.replace(/\bbw_/g, 'bw-');
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Convert a TACO object (or array of TACOs) to an HTML string.
|
|
326
|
+
*
|
|
327
|
+
* This is the core rendering function — it works in both Node.js and browsers.
|
|
328
|
+
* Use it for server-side rendering, static site generation, or generating
|
|
329
|
+
* HTML snippets. Content is HTML-escaped by default; pass `{ raw: true }`
|
|
330
|
+
* to insert raw HTML.
|
|
331
|
+
*
|
|
332
|
+
* @param {Object|Array|string} taco - TACO object, array of TACOs, or string
|
|
333
|
+
* @param {Object} [options] - Rendering options
|
|
334
|
+
* @param {boolean} [options.raw=false] - If true, skip HTML escaping on content
|
|
335
|
+
* @returns {string} HTML string
|
|
336
|
+
* @category DOM Generation
|
|
337
|
+
* @see bw.createDOM
|
|
338
|
+
* @see bw.DOM
|
|
339
|
+
* @example
|
|
340
|
+
* bw.html({ t: 'h1', c: 'Hello' })
|
|
341
|
+
* // => '<h1>Hello</h1>'
|
|
342
|
+
*
|
|
343
|
+
* bw.html({ t: 'div', a: { class: 'card' }, c: [
|
|
344
|
+
* { t: 'p', c: 'Content here' }
|
|
345
|
+
* ]})
|
|
346
|
+
* // => '<div class="card"><p>Content here</p></div>'
|
|
347
|
+
*/
|
|
348
|
+
bw.html = function(taco, options = {}) {
|
|
349
|
+
// Handle null/undefined
|
|
350
|
+
if (taco == null) return '';
|
|
351
|
+
|
|
352
|
+
// Handle arrays of TACOs
|
|
353
|
+
if (Array.isArray(taco)) {
|
|
354
|
+
return taco.map(t => bw.html(t, options)).join('');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Handle primitives and non-TACO objects
|
|
358
|
+
if (typeof taco !== 'object' || !taco.t) {
|
|
359
|
+
return options.raw ? String(taco) : bw.escapeHTML(String(taco));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
|
|
363
|
+
|
|
364
|
+
// Self-closing tags
|
|
365
|
+
const selfClosing = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
|
|
366
|
+
'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
|
367
|
+
const isSelfClosing = selfClosing.includes(tag.toLowerCase());
|
|
368
|
+
|
|
369
|
+
// Build attributes string
|
|
370
|
+
let attrStr = '';
|
|
371
|
+
|
|
372
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
373
|
+
// Skip null, undefined, false
|
|
374
|
+
if (value == null || value === false) continue;
|
|
375
|
+
|
|
376
|
+
// Skip event handlers (they're for DOM only)
|
|
377
|
+
if (key.startsWith('on')) continue;
|
|
378
|
+
|
|
379
|
+
if (key === 'style' && typeof value === 'object') {
|
|
380
|
+
// Convert style object to string
|
|
381
|
+
const styleStr = Object.entries(value)
|
|
382
|
+
.filter(([, v]) => v != null)
|
|
383
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
384
|
+
.join(';');
|
|
385
|
+
if (styleStr) {
|
|
386
|
+
attrStr += ` style="${bw.escapeHTML(styleStr)}"`;
|
|
387
|
+
}
|
|
388
|
+
} else if (key === 'class') {
|
|
389
|
+
// Handle class as array or string, normalize bw_ to bw-
|
|
390
|
+
const classStr = bw.normalizeClass(
|
|
391
|
+
Array.isArray(value)
|
|
392
|
+
? value.filter(Boolean).join(' ')
|
|
393
|
+
: String(value)
|
|
394
|
+
);
|
|
395
|
+
if (classStr) {
|
|
396
|
+
attrStr += ` class="${bw.escapeHTML(classStr)}"`;
|
|
397
|
+
}
|
|
398
|
+
} else if (value === true) {
|
|
399
|
+
// Boolean attributes
|
|
400
|
+
attrStr += ` ${key}`;
|
|
401
|
+
} else {
|
|
402
|
+
// Regular attributes
|
|
403
|
+
attrStr += ` ${key}="${bw.escapeHTML(String(value))}"`;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Add bw-id as a class if lifecycle hooks present
|
|
408
|
+
if ((opts.mounted || opts.unmount) && !attrs.class?.includes('bw-id-')) {
|
|
409
|
+
const id = opts.bw_id || bw.uuid();
|
|
410
|
+
attrStr = attrStr.replace(/class="([^"]*)"/, (_match, classes) => {
|
|
411
|
+
return `class="${classes} bw-id-${id}"`.trim();
|
|
412
|
+
});
|
|
413
|
+
if (!attrStr.includes('class=')) {
|
|
414
|
+
attrStr += ` class="bw-id-${id}"`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Build HTML
|
|
419
|
+
if (isSelfClosing) {
|
|
420
|
+
return `<${tag}${attrStr} />`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Process content recursively
|
|
424
|
+
const contentStr = content != null ? bw.html(content, options) : '';
|
|
425
|
+
|
|
426
|
+
return `<${tag}${attrStr}>${contentStr}</${tag}>`;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Create a live DOM element from a TACO object (browser only).
|
|
431
|
+
*
|
|
432
|
+
* Unlike `bw.html()` which returns a string, this creates real DOM elements
|
|
433
|
+
* with event handlers, lifecycle hooks (mounted/unmount), and state. Used
|
|
434
|
+
* internally by `bw.DOM()`. Throws in Node.js — use `bw.html()` instead.
|
|
435
|
+
*
|
|
436
|
+
* @param {Object} taco - TACO object with {t, a, c, o}
|
|
437
|
+
* @param {Object} [options] - Creation options
|
|
438
|
+
* @returns {Element|Text} DOM element or text node
|
|
439
|
+
* @category DOM Generation
|
|
440
|
+
* @see bw.html
|
|
441
|
+
* @see bw.DOM
|
|
442
|
+
* @example
|
|
443
|
+
* var el = bw.createDOM({
|
|
444
|
+
* t: 'button',
|
|
445
|
+
* a: { class: 'bw-btn', onclick: () => alert('clicked') },
|
|
446
|
+
* c: 'Click Me'
|
|
447
|
+
* });
|
|
448
|
+
* document.body.appendChild(el);
|
|
449
|
+
*/
|
|
450
|
+
bw.createDOM = function(taco, options = {}) {
|
|
451
|
+
if (!bw._isBrowser) {
|
|
452
|
+
throw new Error('bw.createDOM requires a DOM environment (document/window). Use bw.html() instead.');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Handle null/undefined
|
|
456
|
+
if (taco == null) return document.createTextNode('');
|
|
457
|
+
|
|
458
|
+
// Handle text nodes
|
|
459
|
+
if (typeof taco !== 'object' || !taco.t) {
|
|
460
|
+
return document.createTextNode(String(taco));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { t: tag, a: attrs = {}, c: content, o: opts = {} } = taco;
|
|
464
|
+
|
|
465
|
+
// Create element
|
|
466
|
+
const el = document.createElement(tag);
|
|
467
|
+
|
|
468
|
+
// Set attributes
|
|
469
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
470
|
+
if (value == null || value === false) continue;
|
|
471
|
+
|
|
472
|
+
if (key === 'style' && typeof value === 'object') {
|
|
473
|
+
// Apply styles directly
|
|
474
|
+
Object.assign(el.style, value);
|
|
475
|
+
} else if (key === 'class') {
|
|
476
|
+
// Handle class as array or string, normalize bw_ to bw-
|
|
477
|
+
const classStr = bw.normalizeClass(
|
|
478
|
+
Array.isArray(value)
|
|
479
|
+
? value.filter(Boolean).join(' ')
|
|
480
|
+
: String(value)
|
|
481
|
+
);
|
|
482
|
+
if (classStr) {
|
|
483
|
+
el.className = classStr;
|
|
484
|
+
}
|
|
485
|
+
} else if (key.startsWith('on') && typeof value === 'function') {
|
|
486
|
+
// Event handlers
|
|
487
|
+
const eventName = key.slice(2).toLowerCase();
|
|
488
|
+
el.addEventListener(eventName, value);
|
|
489
|
+
} else if (key === 'value' && tag === 'input') {
|
|
490
|
+
// Special handling for input value
|
|
491
|
+
el.value = value;
|
|
492
|
+
} else if (value === true) {
|
|
493
|
+
// Boolean attributes
|
|
494
|
+
el.setAttribute(key, '');
|
|
495
|
+
} else {
|
|
496
|
+
// Regular attributes
|
|
497
|
+
el.setAttribute(key, String(value));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Add children
|
|
502
|
+
if (content != null) {
|
|
503
|
+
if (Array.isArray(content)) {
|
|
504
|
+
content.forEach(child => {
|
|
505
|
+
if (child != null) {
|
|
506
|
+
el.appendChild(bw.createDOM(child, options));
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
} else if (typeof content === 'object' && content.t) {
|
|
510
|
+
el.appendChild(bw.createDOM(content, options));
|
|
511
|
+
} else {
|
|
512
|
+
el.textContent = String(content);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Handle lifecycle hooks and state
|
|
517
|
+
if (opts.mounted || opts.unmount || opts.render || opts.state) {
|
|
518
|
+
const id = attrs['data-bw-id'] || bw.uuid();
|
|
519
|
+
el.setAttribute('data-bw-id', id);
|
|
520
|
+
|
|
521
|
+
// Store state
|
|
522
|
+
if (opts.state) {
|
|
523
|
+
el._bw_state = opts.state;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// o.render — first-class render function (replaces mounted boilerplate)
|
|
527
|
+
if (opts.render) {
|
|
528
|
+
el._bw_render = opts.render;
|
|
529
|
+
|
|
530
|
+
if (opts.mounted) {
|
|
531
|
+
console.warn('bw.createDOM: o.render and o.mounted are mutually exclusive. o.render wins.');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Queue initial render (same timing as mounted)
|
|
535
|
+
if (document.body.contains(el)) {
|
|
536
|
+
opts.render(el, el._bw_state || {});
|
|
537
|
+
} else {
|
|
538
|
+
requestAnimationFrame(() => {
|
|
539
|
+
if (document.body.contains(el)) {
|
|
540
|
+
opts.render(el, el._bw_state || {});
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
} else if (opts.mounted) {
|
|
545
|
+
// Queue mounted callback (legacy pattern)
|
|
546
|
+
if (document.body.contains(el)) {
|
|
547
|
+
opts.mounted(el, el._bw_state || {});
|
|
548
|
+
} else {
|
|
549
|
+
requestAnimationFrame(() => {
|
|
550
|
+
if (document.body.contains(el)) {
|
|
551
|
+
opts.mounted(el, el._bw_state || {});
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Store unmount callback
|
|
558
|
+
if (opts.unmount) {
|
|
559
|
+
bw._unmountCallbacks.set(id, () => {
|
|
560
|
+
opts.unmount(el, el._bw_state || {});
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return el;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Mount a TACO object into a DOM element, replacing its contents (browser only).
|
|
570
|
+
*
|
|
571
|
+
* This is the primary way to render bitwrench UI to the page. It cleans up
|
|
572
|
+
* any existing children (calling unmount hooks), then renders the TACO into
|
|
573
|
+
* the target. The target element itself is preserved — only its children change.
|
|
574
|
+
*
|
|
575
|
+
* @param {string|Element} target - CSS selector or DOM element to mount into
|
|
576
|
+
* @param {Object} taco - TACO object to render
|
|
577
|
+
* @param {Object} [options] - Mount options
|
|
578
|
+
* @returns {Element} Target element
|
|
579
|
+
* @category DOM Generation
|
|
580
|
+
* @see bw.html
|
|
581
|
+
* @see bw.createDOM
|
|
582
|
+
* @see bw.cleanup
|
|
583
|
+
* @example
|
|
584
|
+
* bw.DOM('#app', {
|
|
585
|
+
* t: 'div', a: { class: 'card' },
|
|
586
|
+
* c: [
|
|
587
|
+
* { t: 'h2', c: 'Hello' },
|
|
588
|
+
* { t: 'p', c: 'Built with bitwrench.' }
|
|
589
|
+
* ]
|
|
590
|
+
* });
|
|
591
|
+
*/
|
|
592
|
+
bw.DOM = function(target, taco, options = {}) {
|
|
593
|
+
if (!bw._isBrowser) {
|
|
594
|
+
throw new Error('bw.DOM requires a DOM environment (document/window). Use bw.html() instead.');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Get target element
|
|
598
|
+
const targetEl = typeof target === 'string'
|
|
599
|
+
? document.querySelector(target)
|
|
600
|
+
: target;
|
|
601
|
+
|
|
602
|
+
if (!targetEl) {
|
|
603
|
+
console.error('bw.DOM: Target element not found:', target);
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Clean up existing children (but preserve the target's own state, render, and subs —
|
|
608
|
+
// the target is the mount point, not the content being replaced)
|
|
609
|
+
const savedState = targetEl._bw_state;
|
|
610
|
+
const savedRender = targetEl._bw_render;
|
|
611
|
+
const savedBwId = targetEl.getAttribute('data-bw-id');
|
|
612
|
+
const savedSubs = targetEl._bw_subs;
|
|
613
|
+
|
|
614
|
+
// Temporarily remove _bw_subs so cleanup doesn't call them
|
|
615
|
+
// (children's subs will still be cleaned up normally)
|
|
616
|
+
delete targetEl._bw_subs;
|
|
617
|
+
|
|
618
|
+
bw.cleanup(targetEl);
|
|
619
|
+
|
|
620
|
+
// Restore the target's own state/render/subs after cleanup
|
|
621
|
+
if (savedState !== undefined) targetEl._bw_state = savedState;
|
|
622
|
+
if (savedRender) targetEl._bw_render = savedRender;
|
|
623
|
+
if (savedBwId) targetEl.setAttribute('data-bw-id', savedBwId);
|
|
624
|
+
if (savedSubs) targetEl._bw_subs = savedSubs;
|
|
625
|
+
|
|
626
|
+
// Clear and mount new content
|
|
627
|
+
targetEl.innerHTML = '';
|
|
628
|
+
|
|
629
|
+
if (taco != null) {
|
|
630
|
+
// Handle component handles (objects with element property)
|
|
631
|
+
if (taco.element instanceof Element) {
|
|
632
|
+
targetEl.appendChild(taco.element);
|
|
633
|
+
}
|
|
634
|
+
// Handle arrays
|
|
635
|
+
else if (Array.isArray(taco)) {
|
|
636
|
+
taco.forEach(t => {
|
|
637
|
+
if (t != null) {
|
|
638
|
+
if (t.element instanceof Element) {
|
|
639
|
+
targetEl.appendChild(t.element);
|
|
640
|
+
} else {
|
|
641
|
+
targetEl.appendChild(bw.createDOM(t, options));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
// Handle TACO objects
|
|
647
|
+
else {
|
|
648
|
+
targetEl.appendChild(bw.createDOM(taco, options));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return targetEl;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Compile props into getter/setter functions for reactive updates.
|
|
657
|
+
*
|
|
658
|
+
* Used internally by `bw.renderComponent()`. Creates a proxy-like object
|
|
659
|
+
* where setting a property triggers `handle.onPropChange()`.
|
|
660
|
+
*
|
|
661
|
+
* @param {Object} handle - Component handle
|
|
662
|
+
* @param {Object} props - Initial props
|
|
663
|
+
* @returns {Object} Compiled props object with getters/setters
|
|
664
|
+
* @category DOM Generation
|
|
665
|
+
*/
|
|
666
|
+
bw.compileProps = function(handle, props = {}) {
|
|
667
|
+
const compiledProps = {};
|
|
668
|
+
|
|
669
|
+
Object.keys(props).forEach(key => {
|
|
670
|
+
// Create getter/setter for each prop
|
|
671
|
+
Object.defineProperty(compiledProps, key, {
|
|
672
|
+
get() {
|
|
673
|
+
return handle._props[key];
|
|
674
|
+
},
|
|
675
|
+
set(value) {
|
|
676
|
+
const oldValue = handle._props[key];
|
|
677
|
+
if (oldValue !== value) {
|
|
678
|
+
handle._props[key] = value;
|
|
679
|
+
// Trigger update if prop changed
|
|
680
|
+
if (handle.onPropChange) {
|
|
681
|
+
handle.onPropChange(key, value, oldValue);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
enumerable: true,
|
|
686
|
+
configurable: true
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
return compiledProps;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Render a TACO component and return an enhanced handle object.
|
|
695
|
+
*
|
|
696
|
+
* The handle provides compiled props, state management, child registration,
|
|
697
|
+
* and a destroy method. Used internally by `bw.createCard()`, `bw.createTable()`, etc.
|
|
698
|
+
*
|
|
699
|
+
* @param {Object} taco - TACO object to render
|
|
700
|
+
* @param {Object} [options] - Render options
|
|
701
|
+
* @returns {Object} Component handle with element, props, state, update(), destroy()
|
|
702
|
+
* @category DOM Generation
|
|
703
|
+
*/
|
|
704
|
+
bw.renderComponent = function(taco, options = {}) {
|
|
705
|
+
const element = bw.createDOM(taco, options);
|
|
706
|
+
|
|
707
|
+
// Enhanced handle with prop compilation
|
|
708
|
+
const handle = {
|
|
709
|
+
element,
|
|
710
|
+
taco,
|
|
711
|
+
_props: { ...taco.a }, // Store props internally
|
|
712
|
+
_state: taco.o?.state || {},
|
|
713
|
+
_children: {}, // Store child component references
|
|
714
|
+
|
|
715
|
+
// Get compiled props with getters/setters
|
|
716
|
+
get props() {
|
|
717
|
+
if (!this._compiledProps) {
|
|
718
|
+
this._compiledProps = bw.compileProps(this, this._props);
|
|
719
|
+
}
|
|
720
|
+
return this._compiledProps;
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Query all matching elements within this component
|
|
725
|
+
* @param {string} selector - CSS selector
|
|
726
|
+
* @returns {NodeList} Matching elements
|
|
727
|
+
*/
|
|
728
|
+
$(selector) {
|
|
729
|
+
return this.element.querySelectorAll(selector);
|
|
730
|
+
},
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Query the first matching element within this component
|
|
734
|
+
* @param {string} selector - CSS selector
|
|
735
|
+
* @returns {Element|null} First matching element or null
|
|
736
|
+
*/
|
|
737
|
+
$first(selector) {
|
|
738
|
+
return this.element.querySelector(selector);
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Update component with new props and re-render in place
|
|
743
|
+
* @param {Object} newProps - Properties to merge into current props
|
|
744
|
+
* @returns {Object} this handle (for chaining)
|
|
745
|
+
*/
|
|
746
|
+
update(newProps) {
|
|
747
|
+
// Update internal props
|
|
748
|
+
Object.assign(this._props, newProps);
|
|
749
|
+
|
|
750
|
+
// Rebuild TACO with new props
|
|
751
|
+
const newTaco = { ...this.taco, a: { ...this.taco.a, ...newProps } };
|
|
752
|
+
const newElement = bw.createDOM(newTaco, options);
|
|
753
|
+
|
|
754
|
+
// Replace in DOM
|
|
755
|
+
this.element.replaceWith(newElement);
|
|
756
|
+
this.element = newElement;
|
|
757
|
+
this.taco = newTaco;
|
|
758
|
+
|
|
759
|
+
return this;
|
|
760
|
+
},
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Re-render the component from its current TACO, replacing the DOM element
|
|
764
|
+
* @returns {Object} this handle (for chaining)
|
|
765
|
+
*/
|
|
766
|
+
render() {
|
|
767
|
+
const newElement = bw.createDOM(this.taco, options);
|
|
768
|
+
this.element.replaceWith(newElement);
|
|
769
|
+
this.element = newElement;
|
|
770
|
+
return this;
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Called when a compiled prop value changes. Override to customize behavior.
|
|
775
|
+
* Default implementation triggers a full re-render.
|
|
776
|
+
* @param {string} key - Property name that changed
|
|
777
|
+
* @param {*} newValue - New property value
|
|
778
|
+
* @param {*} oldValue - Previous property value
|
|
779
|
+
*/
|
|
780
|
+
onPropChange(_key, _newValue, _oldValue) {
|
|
781
|
+
// Auto re-render on prop change by default
|
|
782
|
+
this.render();
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
// State management
|
|
786
|
+
get state() {
|
|
787
|
+
return this._state;
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
set state(newState) {
|
|
791
|
+
this._state = newState;
|
|
792
|
+
this.render();
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Merge state updates and re-render the component
|
|
797
|
+
* @param {Object} updates - State properties to merge
|
|
798
|
+
* @returns {Object} this handle (for chaining)
|
|
799
|
+
*/
|
|
800
|
+
setState(updates) {
|
|
801
|
+
Object.assign(this._state, updates);
|
|
802
|
+
this.render();
|
|
803
|
+
return this;
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Register a child component under a name for later retrieval
|
|
808
|
+
* @param {string} name - Child name key
|
|
809
|
+
* @param {Object} component - Child component handle
|
|
810
|
+
* @returns {Object} this handle (for chaining)
|
|
811
|
+
*/
|
|
812
|
+
addChild(name, component) {
|
|
813
|
+
this._children[name] = component;
|
|
814
|
+
return this;
|
|
815
|
+
},
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Retrieve a registered child component by name
|
|
819
|
+
* @param {string} name - Child name key
|
|
820
|
+
* @returns {Object|undefined} Child component handle
|
|
821
|
+
*/
|
|
822
|
+
getChild(name) {
|
|
823
|
+
return this._children[name];
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Destroy this component and all registered children
|
|
828
|
+
*
|
|
829
|
+
* Calls destroy() recursively on children, runs bw.cleanup(),
|
|
830
|
+
* removes the element from DOM, and clears all internal references.
|
|
831
|
+
*/
|
|
832
|
+
destroy() {
|
|
833
|
+
// Destroy children first
|
|
834
|
+
Object.values(this._children).forEach(child => {
|
|
835
|
+
if (child && child.destroy) child.destroy();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// Clean up this component
|
|
839
|
+
bw.cleanup(this.element);
|
|
840
|
+
this.element.remove();
|
|
841
|
+
|
|
842
|
+
// Clear references
|
|
843
|
+
this._children = {};
|
|
844
|
+
this._props = {};
|
|
845
|
+
this._state = {};
|
|
846
|
+
this._compiledProps = null;
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// Store handle reference on element
|
|
851
|
+
element._bwHandle = handle;
|
|
852
|
+
|
|
853
|
+
return handle;
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Clean up a DOM element and all its children by calling unmount callbacks,
|
|
858
|
+
* removing pub/sub subscriptions, and clearing state/render references.
|
|
859
|
+
*
|
|
860
|
+
* Called automatically by `bw.DOM()` before re-rendering. Call manually when
|
|
861
|
+
* removing elements to prevent memory leaks from orphaned callbacks.
|
|
862
|
+
*
|
|
863
|
+
* @param {Element} element - DOM element to clean up
|
|
864
|
+
* @category DOM Generation
|
|
865
|
+
* @see bw.DOM
|
|
866
|
+
* @example
|
|
867
|
+
* var el = document.querySelector('#my-widget');
|
|
868
|
+
* bw.cleanup(el); // runs unmount hooks, clears _bw_state, _bw_render
|
|
869
|
+
* el.remove(); // safe to remove from DOM now
|
|
870
|
+
*/
|
|
871
|
+
bw.cleanup = function(element) {
|
|
872
|
+
if (!bw._isBrowser || !element) return;
|
|
873
|
+
|
|
874
|
+
// Find all elements with data-bw-id
|
|
875
|
+
const elements = element.querySelectorAll('[data-bw-id]');
|
|
876
|
+
|
|
877
|
+
elements.forEach(el => {
|
|
878
|
+
const id = el.getAttribute('data-bw-id');
|
|
879
|
+
const callback = bw._unmountCallbacks.get(id);
|
|
880
|
+
|
|
881
|
+
if (callback) {
|
|
882
|
+
callback();
|
|
883
|
+
bw._unmountCallbacks.delete(id);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Clean up pub/sub subscriptions tied to this element
|
|
887
|
+
if (el._bw_subs) {
|
|
888
|
+
el._bw_subs.forEach(function(unsub) { unsub(); });
|
|
889
|
+
delete el._bw_subs;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Clean up state and render
|
|
893
|
+
delete el._bw_state;
|
|
894
|
+
delete el._bw_render;
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Check element itself
|
|
898
|
+
const id = element.getAttribute('data-bw-id');
|
|
899
|
+
if (id) {
|
|
900
|
+
const callback = bw._unmountCallbacks.get(id);
|
|
901
|
+
if (callback) {
|
|
902
|
+
callback();
|
|
903
|
+
bw._unmountCallbacks.delete(id);
|
|
904
|
+
}
|
|
905
|
+
// Clean up pub/sub subscriptions tied to element itself
|
|
906
|
+
if (element._bw_subs) {
|
|
907
|
+
element._bw_subs.forEach(function(unsub) { unsub(); });
|
|
908
|
+
delete element._bw_subs;
|
|
909
|
+
}
|
|
910
|
+
delete element._bw_state;
|
|
911
|
+
delete element._bw_render;
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// ===================================================================================
|
|
916
|
+
// State Management: update, patch, emit/on
|
|
917
|
+
// ===================================================================================
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Trigger re-render of a component by calling its stored `o.render` function.
|
|
921
|
+
*
|
|
922
|
+
* This is the recommended way to update a component after changing its state.
|
|
923
|
+
* Calls `el._bw_render(el, state)` and emits `bw:statechange` so other
|
|
924
|
+
* components can react without tight coupling.
|
|
925
|
+
*
|
|
926
|
+
* @param {string|Element} target - CSS selector or DOM element with _bw_render
|
|
927
|
+
* @returns {Element|null} The element, or null if not found / no render function
|
|
928
|
+
* @category State Management
|
|
929
|
+
* @see bw.patch
|
|
930
|
+
* @example
|
|
931
|
+
* // Given a counter element with o.render
|
|
932
|
+
* el._bw_state.count++;
|
|
933
|
+
* bw.update(el); // re-renders, emits bw:statechange
|
|
934
|
+
*/
|
|
935
|
+
bw.update = function(target) {
|
|
936
|
+
var el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
937
|
+
if (el && el._bw_render) {
|
|
938
|
+
el._bw_render(el, el._bw_state || {});
|
|
939
|
+
bw.emit(el, 'statechange', el._bw_state);
|
|
940
|
+
}
|
|
941
|
+
return el || null;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Targeted DOM update by element ID — change one element's content or attribute
|
|
946
|
+
* without rebuilding the entire component tree.
|
|
947
|
+
*
|
|
948
|
+
* Use `bw.patch()` for lightweight value updates (scores, labels, counters)
|
|
949
|
+
* and `bw.update()` for full structural re-renders.
|
|
950
|
+
*
|
|
951
|
+
* @param {string|Element} id - Element ID string or DOM element
|
|
952
|
+
* @param {string|Object} content - New text content, or TACO object to replace children
|
|
953
|
+
* @param {string} [attr] - If provided, sets this attribute instead of content
|
|
954
|
+
* @returns {Element|null} The patched element, or null if not found
|
|
955
|
+
* @category State Management
|
|
956
|
+
* @see bw.patchAll
|
|
957
|
+
* @see bw.update
|
|
958
|
+
* @example
|
|
959
|
+
* bw.patch('score-display', '42'); // update text content
|
|
960
|
+
* bw.patch('status', 'active', 'class'); // update an attribute
|
|
961
|
+
* bw.patch('info', { t: 'em', c: 'new' }); // replace children with TACO
|
|
962
|
+
*/
|
|
963
|
+
bw.patch = function(id, content, attr) {
|
|
964
|
+
var el = typeof id === 'string' ? document.getElementById(id) : id;
|
|
965
|
+
if (!el) return null;
|
|
966
|
+
|
|
967
|
+
if (attr) {
|
|
968
|
+
// Patch an attribute
|
|
969
|
+
el.setAttribute(attr, String(content));
|
|
970
|
+
} else if (typeof content === 'object' && content !== null && content.t) {
|
|
971
|
+
// Patch with a TACO — replace children
|
|
972
|
+
el.innerHTML = '';
|
|
973
|
+
el.appendChild(bw.createDOM(content));
|
|
974
|
+
} else {
|
|
975
|
+
// Patch text content
|
|
976
|
+
el.textContent = String(content);
|
|
977
|
+
}
|
|
978
|
+
return el;
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Batch version of `bw.patch()` — update multiple elements in one call.
|
|
983
|
+
*
|
|
984
|
+
* Useful for updating several independent values simultaneously,
|
|
985
|
+
* such as a dashboard with multiple counters.
|
|
986
|
+
*
|
|
987
|
+
* @param {Object} patches - Map of { elementId: newContent, ... }
|
|
988
|
+
* @returns {Object} Map of { elementId: patchedElement|null, ... }
|
|
989
|
+
* @category State Management
|
|
990
|
+
* @see bw.patch
|
|
991
|
+
* @example
|
|
992
|
+
* bw.patchAll({
|
|
993
|
+
* 'cpu-display': '78%',
|
|
994
|
+
* 'mem-display': '4.2 GB',
|
|
995
|
+
* 'disk-display': '120 GB free'
|
|
996
|
+
* });
|
|
997
|
+
*/
|
|
998
|
+
bw.patchAll = function(patches) {
|
|
999
|
+
var results = {};
|
|
1000
|
+
for (var id in patches) {
|
|
1001
|
+
if (Object.prototype.hasOwnProperty.call(patches, id)) {
|
|
1002
|
+
results[id] = bw.patch(id, patches[id]);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return results;
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Emit a custom DOM event on an element.
|
|
1010
|
+
*
|
|
1011
|
+
* Events are prefixed with `bw:` to avoid collision with native events and
|
|
1012
|
+
* bubble by default so ancestor elements can listen. Use with `bw.on()` for
|
|
1013
|
+
* DOM-scoped communication between components.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {string|Element} target - CSS selector or DOM element
|
|
1016
|
+
* @param {string} eventName - Event name (will be prefixed with 'bw:')
|
|
1017
|
+
* @param {*} [detail] - Data to pass with the event
|
|
1018
|
+
* @category Events (DOM)
|
|
1019
|
+
* @see bw.on
|
|
1020
|
+
* @example
|
|
1021
|
+
* bw.emit('#my-widget', 'statechange', { count: 42 });
|
|
1022
|
+
* // Dispatches CustomEvent 'bw:statechange' on the element
|
|
1023
|
+
*/
|
|
1024
|
+
bw.emit = function(target, eventName, detail) {
|
|
1025
|
+
var el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1026
|
+
if (el) {
|
|
1027
|
+
el.dispatchEvent(new CustomEvent('bw:' + eventName, {
|
|
1028
|
+
bubbles: true,
|
|
1029
|
+
detail: detail || {}
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Listen for a custom bitwrench event on a DOM element.
|
|
1036
|
+
*
|
|
1037
|
+
* Handler receives `(detail, event)` for convenience — the detail object
|
|
1038
|
+
* is the first argument so you don't need to destructure `e.detail`.
|
|
1039
|
+
* Events bubble, so you can listen on an ancestor element.
|
|
1040
|
+
*
|
|
1041
|
+
* @param {string|Element} target - CSS selector or DOM element
|
|
1042
|
+
* @param {string} eventName - Event name (will be prefixed with 'bw:')
|
|
1043
|
+
* @param {Function} handler - Called with (detail, event)
|
|
1044
|
+
* @returns {Element|null} The element (for chaining), or null if not found
|
|
1045
|
+
* @category Events (DOM)
|
|
1046
|
+
* @see bw.emit
|
|
1047
|
+
* @example
|
|
1048
|
+
* bw.on(document.body, 'statechange', function(detail) {
|
|
1049
|
+
* console.log('State changed:', detail);
|
|
1050
|
+
* });
|
|
1051
|
+
*/
|
|
1052
|
+
bw.on = function(target, eventName, handler) {
|
|
1053
|
+
var el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1054
|
+
if (el) {
|
|
1055
|
+
el.addEventListener('bw:' + eventName, function(e) {
|
|
1056
|
+
handler(e.detail, e);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
return el || null;
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
// ===================================================================================
|
|
1063
|
+
// Topic-Based Pub/Sub: bw.pub(), bw.sub(), bw.unsub()
|
|
1064
|
+
//
|
|
1065
|
+
// Separate from emit/on (DOM-scoped CustomEvents). Pub/sub is application-scoped,
|
|
1066
|
+
// topic-based, and decoupled from the DOM tree. Try/catch per subscriber so one
|
|
1067
|
+
// bad handler can't break others.
|
|
1068
|
+
// ===================================================================================
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Publish to a topic, calling all subscribers in registration order.
|
|
1072
|
+
*
|
|
1073
|
+
* Application-scoped pub/sub decoupled from the DOM tree. Each subscriber
|
|
1074
|
+
* is wrapped in try/catch so one bad handler can't break others.
|
|
1075
|
+
* Use `bw.pub()`/`bw.sub()` for app-wide communication; use `bw.emit()`/`bw.on()`
|
|
1076
|
+
* for DOM-scoped events.
|
|
1077
|
+
*
|
|
1078
|
+
* @param {string} topic - Topic name (plain string, no prefix)
|
|
1079
|
+
* @param {*} [detail] - Data to pass to subscribers
|
|
1080
|
+
* @returns {number} Count of successfully called subscribers
|
|
1081
|
+
* @category Pub/Sub
|
|
1082
|
+
* @see bw.sub
|
|
1083
|
+
* @example
|
|
1084
|
+
* bw.pub('score:updated', { player: 'X', score: 10 });
|
|
1085
|
+
*/
|
|
1086
|
+
bw.pub = function(topic, detail) {
|
|
1087
|
+
var subs = bw._topics[topic];
|
|
1088
|
+
if (!subs || subs.length === 0) return 0;
|
|
1089
|
+
var snapshot = subs.slice(); // safe against unsub during iteration
|
|
1090
|
+
var called = 0;
|
|
1091
|
+
for (var i = 0; i < snapshot.length; i++) {
|
|
1092
|
+
try {
|
|
1093
|
+
snapshot[i].handler(detail);
|
|
1094
|
+
called++;
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
console.warn('bw.pub: subscriber error on topic "' + topic + '":', err);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return called;
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Subscribe to a topic. Returns an unsub() function.
|
|
1104
|
+
*
|
|
1105
|
+
* Optional third argument ties the subscription to a DOM element's lifecycle —
|
|
1106
|
+
* when `bw.cleanup()` is called on that element, the subscription is automatically
|
|
1107
|
+
* removed, preventing memory leaks.
|
|
1108
|
+
*
|
|
1109
|
+
* @param {string} topic - Topic name
|
|
1110
|
+
* @param {Function} handler - Called with (detail) on each publish
|
|
1111
|
+
* @param {Element} [el] - Optional DOM element to tie lifecycle to
|
|
1112
|
+
* @returns {Function} Call to unsubscribe
|
|
1113
|
+
* @category Pub/Sub
|
|
1114
|
+
* @see bw.pub
|
|
1115
|
+
* @see bw.unsub
|
|
1116
|
+
* @example
|
|
1117
|
+
* var unsub = bw.sub('score:updated', function(detail) {
|
|
1118
|
+
* console.log(detail.player, 'scored', detail.score);
|
|
1119
|
+
* });
|
|
1120
|
+
* // Later: unsub() to stop listening
|
|
1121
|
+
*/
|
|
1122
|
+
bw.sub = function(topic, handler, el) {
|
|
1123
|
+
var id = ++bw._subIdCounter;
|
|
1124
|
+
if (!bw._topics[topic]) bw._topics[topic] = [];
|
|
1125
|
+
bw._topics[topic].push({ handler: handler, id: id });
|
|
1126
|
+
|
|
1127
|
+
var unsub = function() {
|
|
1128
|
+
var subs = bw._topics[topic];
|
|
1129
|
+
if (!subs) return;
|
|
1130
|
+
bw._topics[topic] = subs.filter(function(s) { return s.id !== id; });
|
|
1131
|
+
if (bw._topics[topic].length === 0) delete bw._topics[topic];
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
// Tie to element lifecycle if provided
|
|
1135
|
+
if (el) {
|
|
1136
|
+
if (!el._bw_subs) el._bw_subs = [];
|
|
1137
|
+
el._bw_subs.push(unsub);
|
|
1138
|
+
// Ensure element has data-bw-id so bw.cleanup() finds it
|
|
1139
|
+
if (!el.getAttribute('data-bw-id')) {
|
|
1140
|
+
var bwId = 'bw_sub_' + id;
|
|
1141
|
+
el.setAttribute('data-bw-id', bwId);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return unsub;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Unsubscribe a handler by reference from a topic.
|
|
1150
|
+
*
|
|
1151
|
+
* Removes ALL instances of the given handler on the topic.
|
|
1152
|
+
* Alternative to calling the unsub function returned by `bw.sub()`.
|
|
1153
|
+
*
|
|
1154
|
+
* @param {string} topic - Topic name
|
|
1155
|
+
* @param {Function} handler - The handler to remove (by reference equality)
|
|
1156
|
+
* @returns {number} Count of removed subscriptions
|
|
1157
|
+
* @category Pub/Sub
|
|
1158
|
+
* @see bw.sub
|
|
1159
|
+
*/
|
|
1160
|
+
bw.unsub = function(topic, handler) {
|
|
1161
|
+
var subs = bw._topics[topic];
|
|
1162
|
+
if (!subs) return 0;
|
|
1163
|
+
var before = subs.length;
|
|
1164
|
+
bw._topics[topic] = subs.filter(function(s) { return s.handler !== handler; });
|
|
1165
|
+
var removed = before - bw._topics[topic].length;
|
|
1166
|
+
if (bw._topics[topic].length === 0) delete bw._topics[topic];
|
|
1167
|
+
return removed;
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Generate CSS from JavaScript objects.
|
|
1172
|
+
*
|
|
1173
|
+
* Converts an object of `{ selector: { prop: value } }` rules into a CSS string.
|
|
1174
|
+
* CamelCase property names are auto-converted to kebab-case (e.g. `fontSize` → `font-size`).
|
|
1175
|
+
* Accepts nested arrays of rule objects.
|
|
1176
|
+
*
|
|
1177
|
+
* @param {Object|Array|string} rules - CSS rules as JS objects, array of rule objects, or raw CSS string
|
|
1178
|
+
* @param {Object} [options] - Generation options
|
|
1179
|
+
* @param {boolean} [options.minify=false] - Minify output (no whitespace)
|
|
1180
|
+
* @returns {string} CSS string
|
|
1181
|
+
* @category CSS & Styling
|
|
1182
|
+
* @see bw.injectCSS
|
|
1183
|
+
* @example
|
|
1184
|
+
* bw.css({
|
|
1185
|
+
* '.card': { padding: '1rem', fontSize: '14px', borderRadius: '8px' }
|
|
1186
|
+
* })
|
|
1187
|
+
* // => '.card {\n padding: 1rem;\n font-size: 14px;\n border-radius: 8px;\n}'
|
|
1188
|
+
*/
|
|
1189
|
+
bw.css = function(rules, options = {}) {
|
|
1190
|
+
const { minify = false, pretty = !minify } = options;
|
|
1191
|
+
|
|
1192
|
+
if (typeof rules === 'string') return rules;
|
|
1193
|
+
|
|
1194
|
+
let css = '';
|
|
1195
|
+
const indent = pretty ? ' ' : '';
|
|
1196
|
+
const newline = pretty ? '\n' : '';
|
|
1197
|
+
const space = pretty ? ' ' : '';
|
|
1198
|
+
|
|
1199
|
+
if (Array.isArray(rules)) {
|
|
1200
|
+
css = rules.map(rule => bw.css(rule, options)).join(newline);
|
|
1201
|
+
} else if (typeof rules === 'object') {
|
|
1202
|
+
Object.entries(rules).forEach(([selector, styles]) => {
|
|
1203
|
+
if (typeof styles === 'object' && !Array.isArray(styles)) {
|
|
1204
|
+
// Handle @media, @keyframes, @supports — recurse into nested block
|
|
1205
|
+
if (selector.charAt(0) === '@') {
|
|
1206
|
+
const inner = bw.css(styles, options);
|
|
1207
|
+
if (inner) {
|
|
1208
|
+
css += `${selector}${space}{${newline}${inner}${newline}}${newline}`;
|
|
1209
|
+
}
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const declarations = Object.entries(styles)
|
|
1213
|
+
.filter(([, value]) => value != null)
|
|
1214
|
+
.map(([prop, value]) => {
|
|
1215
|
+
// Convert camelCase to kebab-case
|
|
1216
|
+
const kebabProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
1217
|
+
return `${indent}${kebabProp}:${space}${value};`;
|
|
1218
|
+
})
|
|
1219
|
+
.join(newline);
|
|
1220
|
+
|
|
1221
|
+
if (declarations) {
|
|
1222
|
+
css += `${selector}${space}{${newline}${declarations}${newline}}${newline}`;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return css.trim();
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Inject CSS into the document head (browser only).
|
|
1233
|
+
*
|
|
1234
|
+
* Creates or reuses a `<style>` element (identified by `id`). Can accept
|
|
1235
|
+
* raw CSS strings or JS rule objects (which are converted via `bw.css()`).
|
|
1236
|
+
* By default appends to existing content; set `append: false` to replace.
|
|
1237
|
+
*
|
|
1238
|
+
* @param {string|Object|Array} css - CSS string, or JS rule objects to convert
|
|
1239
|
+
* @param {Object} [options] - Injection options
|
|
1240
|
+
* @param {string} [options.id='bw-styles'] - ID for the style element
|
|
1241
|
+
* @param {boolean} [options.append=true] - Append to existing CSS (false to replace)
|
|
1242
|
+
* @returns {Element} The style element
|
|
1243
|
+
* @category CSS & Styling
|
|
1244
|
+
* @see bw.css
|
|
1245
|
+
* @see bw.loadDefaultStyles
|
|
1246
|
+
* @example
|
|
1247
|
+
* bw.injectCSS('.my-class { color: red; }');
|
|
1248
|
+
* bw.injectCSS({ '.card': { padding: '1rem' } }, { id: 'card-styles' });
|
|
1249
|
+
*/
|
|
1250
|
+
bw.injectCSS = function(css, options = {}) {
|
|
1251
|
+
if (!bw._isBrowser) {
|
|
1252
|
+
console.warn('bw.injectCSS requires a DOM environment');
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const { id = 'bw-styles', append = true } = options;
|
|
1257
|
+
|
|
1258
|
+
// Get or create style element
|
|
1259
|
+
let styleEl = document.getElementById(id);
|
|
1260
|
+
|
|
1261
|
+
if (!styleEl) {
|
|
1262
|
+
styleEl = document.createElement('style');
|
|
1263
|
+
styleEl.id = id;
|
|
1264
|
+
styleEl.type = 'text/css';
|
|
1265
|
+
document.head.appendChild(styleEl);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Convert CSS if needed
|
|
1269
|
+
const cssStr = typeof css === 'string' ? css : bw.css(css, options);
|
|
1270
|
+
|
|
1271
|
+
// Set or append CSS
|
|
1272
|
+
if (append && styleEl.textContent) {
|
|
1273
|
+
styleEl.textContent += '\n' + cssStr;
|
|
1274
|
+
} else {
|
|
1275
|
+
styleEl.textContent = cssStr;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return styleEl;
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Merge multiple style objects into one (left-to-right).
|
|
1283
|
+
*
|
|
1284
|
+
* Like `Object.assign()` for styles, but filters out null/undefined arguments.
|
|
1285
|
+
* Compose inline styles or CSS rule objects without mutation.
|
|
1286
|
+
*
|
|
1287
|
+
* @param {...Object} styles - Style objects to merge (left-to-right)
|
|
1288
|
+
* @returns {Object} Merged style object
|
|
1289
|
+
* @category CSS & Styling
|
|
1290
|
+
* @see bw.u
|
|
1291
|
+
* @example
|
|
1292
|
+
* var style = bw.s(bw.u.flex, bw.u.gap4, { color: 'red' });
|
|
1293
|
+
* // => { display: 'flex', gap: '1rem', color: 'red' }
|
|
1294
|
+
*/
|
|
1295
|
+
bw.s = function() {
|
|
1296
|
+
var result = {};
|
|
1297
|
+
for (var i = 0; i < arguments.length; i++) {
|
|
1298
|
+
var arg = arguments[i];
|
|
1299
|
+
if (arg && typeof arg === 'object') Object.assign(result, arg);
|
|
1300
|
+
}
|
|
1301
|
+
return result;
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Pre-built CSS utility objects (like Tailwind utilities, but in JS).
|
|
1306
|
+
*
|
|
1307
|
+
* Compose with `bw.s()` to build inline styles without writing raw CSS strings.
|
|
1308
|
+
* Includes flex, padding, margin, typography, color, border, and transition utilities.
|
|
1309
|
+
*
|
|
1310
|
+
* @category CSS & Styling
|
|
1311
|
+
* @see bw.s
|
|
1312
|
+
* @example
|
|
1313
|
+
* { t: 'div', a: { style: bw.s(bw.u.flex, bw.u.gap4, bw.u.p4) },
|
|
1314
|
+
* c: 'Flexbox with 1rem gap and padding' }
|
|
1315
|
+
*/
|
|
1316
|
+
bw.u = {
|
|
1317
|
+
// Display
|
|
1318
|
+
flex: { display: 'flex' },
|
|
1319
|
+
flexCol: { display: 'flex', flexDirection: 'column' },
|
|
1320
|
+
flexRow: { display: 'flex', flexDirection: 'row' },
|
|
1321
|
+
flexWrap: { display: 'flex', flexWrap: 'wrap' },
|
|
1322
|
+
block: { display: 'block' },
|
|
1323
|
+
inline: { display: 'inline' },
|
|
1324
|
+
hidden: { display: 'none' },
|
|
1325
|
+
|
|
1326
|
+
// Flex alignment
|
|
1327
|
+
justifyCenter: { justifyContent: 'center' },
|
|
1328
|
+
justifyBetween: { justifyContent: 'space-between' },
|
|
1329
|
+
justifyEnd: { justifyContent: 'flex-end' },
|
|
1330
|
+
alignCenter: { alignItems: 'center' },
|
|
1331
|
+
alignStart: { alignItems: 'flex-start' },
|
|
1332
|
+
alignEnd: { alignItems: 'flex-end' },
|
|
1333
|
+
|
|
1334
|
+
// Gap (0.25rem increments)
|
|
1335
|
+
gap1: { gap: '0.25rem' },
|
|
1336
|
+
gap2: { gap: '0.5rem' },
|
|
1337
|
+
gap3: { gap: '0.75rem' },
|
|
1338
|
+
gap4: { gap: '1rem' },
|
|
1339
|
+
gap6: { gap: '1.5rem' },
|
|
1340
|
+
gap8: { gap: '2rem' },
|
|
1341
|
+
|
|
1342
|
+
// Padding
|
|
1343
|
+
p0: { padding: '0' },
|
|
1344
|
+
p1: { padding: '0.25rem' },
|
|
1345
|
+
p2: { padding: '0.5rem' },
|
|
1346
|
+
p3: { padding: '0.75rem' },
|
|
1347
|
+
p4: { padding: '1rem' },
|
|
1348
|
+
p6: { padding: '1.5rem' },
|
|
1349
|
+
p8: { padding: '2rem' },
|
|
1350
|
+
px4: { paddingLeft: '1rem', paddingRight: '1rem' },
|
|
1351
|
+
py2: { paddingTop: '0.5rem', paddingBottom: '0.5rem' },
|
|
1352
|
+
py4: { paddingTop: '1rem', paddingBottom: '1rem' },
|
|
1353
|
+
|
|
1354
|
+
// Margin (same scale)
|
|
1355
|
+
m0: { margin: '0' },
|
|
1356
|
+
m4: { margin: '1rem' },
|
|
1357
|
+
mt2: { marginTop: '0.5rem' },
|
|
1358
|
+
mt4: { marginTop: '1rem' },
|
|
1359
|
+
mb2: { marginBottom: '0.5rem' },
|
|
1360
|
+
mb4: { marginBottom: '1rem' },
|
|
1361
|
+
mx_auto: { marginLeft: 'auto', marginRight: 'auto' },
|
|
1362
|
+
|
|
1363
|
+
// Typography
|
|
1364
|
+
textSm: { fontSize: '0.875rem' },
|
|
1365
|
+
textBase: { fontSize: '1rem' },
|
|
1366
|
+
textLg: { fontSize: '1.125rem' },
|
|
1367
|
+
textXl: { fontSize: '1.25rem' },
|
|
1368
|
+
text2xl: { fontSize: '1.5rem' },
|
|
1369
|
+
text3xl: { fontSize: '1.875rem' },
|
|
1370
|
+
bold: { fontWeight: '700' },
|
|
1371
|
+
semibold: { fontWeight: '600' },
|
|
1372
|
+
italic: { fontStyle: 'italic' },
|
|
1373
|
+
textCenter: { textAlign: 'center' },
|
|
1374
|
+
textRight: { textAlign: 'right' },
|
|
1375
|
+
|
|
1376
|
+
// Colors (from design tokens)
|
|
1377
|
+
bgWhite: { background: '#ffffff' },
|
|
1378
|
+
bgTeal: { background: '#006666', color: '#ffffff' },
|
|
1379
|
+
textWhite: { color: '#ffffff' },
|
|
1380
|
+
textTeal: { color: '#006666' },
|
|
1381
|
+
textMuted: { color: '#888' },
|
|
1382
|
+
|
|
1383
|
+
// Borders
|
|
1384
|
+
rounded: { borderRadius: '0.375rem' },
|
|
1385
|
+
roundedLg: { borderRadius: '0.5rem' },
|
|
1386
|
+
roundedFull: { borderRadius: '9999px' },
|
|
1387
|
+
border: { border: '1px solid #d8d8d8' },
|
|
1388
|
+
|
|
1389
|
+
// Sizing
|
|
1390
|
+
wFull: { width: '100%' },
|
|
1391
|
+
hFull: { height: '100%' },
|
|
1392
|
+
|
|
1393
|
+
// Transitions
|
|
1394
|
+
transition: { transition: 'all 0.2s ease' }
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Generate responsive CSS with media query breakpoints.
|
|
1399
|
+
*
|
|
1400
|
+
* Produces a CSS string with `@media` rules for sm (640px), md (768px),
|
|
1401
|
+
* lg (1024px), and xl (1280px) breakpoints. Pass the result to `bw.injectCSS()`.
|
|
1402
|
+
*
|
|
1403
|
+
* @param {string} selector - CSS selector
|
|
1404
|
+
* @param {Object} breakpoints - Object with keys: base, sm, md, lg, xl
|
|
1405
|
+
* @returns {string} Generated CSS string (pass to bw.injectCSS)
|
|
1406
|
+
* @category CSS & Styling
|
|
1407
|
+
* @see bw.css
|
|
1408
|
+
* @see bw.injectCSS
|
|
1409
|
+
* @example
|
|
1410
|
+
* var css = bw.responsive('.grid', {
|
|
1411
|
+
* base: { gridTemplateColumns: '1fr' },
|
|
1412
|
+
* md: { gridTemplateColumns: '1fr 1fr' },
|
|
1413
|
+
* lg: { gridTemplateColumns: '1fr 1fr 1fr' }
|
|
1414
|
+
* });
|
|
1415
|
+
* bw.injectCSS(css);
|
|
1416
|
+
*/
|
|
1417
|
+
bw.responsive = function(selector, breakpoints) {
|
|
1418
|
+
var sizes = { sm: '640px', md: '768px', lg: '1024px', xl: '1280px' };
|
|
1419
|
+
var parts = [];
|
|
1420
|
+
Object.keys(breakpoints).forEach(function(key) {
|
|
1421
|
+
var rules = {};
|
|
1422
|
+
if (key === 'base') {
|
|
1423
|
+
rules[selector] = breakpoints[key];
|
|
1424
|
+
parts.push(bw.css(rules));
|
|
1425
|
+
} else if (sizes[key]) {
|
|
1426
|
+
rules[selector] = breakpoints[key];
|
|
1427
|
+
parts.push('@media (min-width: ' + sizes[key] + ') {\n' + bw.css(rules) + '\n}');
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
return parts.join('\n');
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* Map/scale a value from one range to another (linear interpolation).
|
|
1435
|
+
*
|
|
1436
|
+
* Useful for converting sensor data, normalizing values, or creating
|
|
1437
|
+
* visual scales. Supports optional clamping and exponential scaling.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {number} x - Input value
|
|
1440
|
+
* @param {number} in0 - Input range start
|
|
1441
|
+
* @param {number} in1 - Input range end
|
|
1442
|
+
* @param {number} out0 - Output range start
|
|
1443
|
+
* @param {number} out1 - Output range end
|
|
1444
|
+
* @param {Object} [options] - Mapping options
|
|
1445
|
+
* @param {boolean} [options.clip=false] - Clamp result to output range
|
|
1446
|
+
* @param {number} [options.expScale=1] - Exponential scaling factor
|
|
1447
|
+
* @returns {number} Mapped value
|
|
1448
|
+
* @category Math
|
|
1449
|
+
* @see bw.clip
|
|
1450
|
+
* @example
|
|
1451
|
+
* bw.mapScale(50, 0, 100, 0, 1) // => 0.5
|
|
1452
|
+
* bw.mapScale(75, 0, 100, 0, 255) // => 191.25
|
|
1453
|
+
*/
|
|
1454
|
+
bw.mapScale = function(x, in0, in1, out0, out1, options = {}) {
|
|
1455
|
+
const { clip = false, expScale = 1 } = options;
|
|
1456
|
+
|
|
1457
|
+
// Normalize to 0-1
|
|
1458
|
+
let normalized = (x - in0) / (in1 - in0);
|
|
1459
|
+
|
|
1460
|
+
// Apply exponential scaling
|
|
1461
|
+
if (expScale !== 1) {
|
|
1462
|
+
normalized = Math.pow(normalized, expScale);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Map to output range
|
|
1466
|
+
let result = normalized * (out1 - out0) + out0;
|
|
1467
|
+
|
|
1468
|
+
// Clip if requested
|
|
1469
|
+
if (clip) {
|
|
1470
|
+
const min = Math.min(out0, out1);
|
|
1471
|
+
const max = Math.max(out0, out1);
|
|
1472
|
+
result = Math.max(min, Math.min(max, result));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
return result;
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Clamp a value between min and max bounds.
|
|
1480
|
+
*
|
|
1481
|
+
* @param {number} value - Value to clamp
|
|
1482
|
+
* @param {number} min - Minimum allowed value
|
|
1483
|
+
* @param {number} max - Maximum allowed value
|
|
1484
|
+
* @returns {number} Clamped value
|
|
1485
|
+
* @category Math
|
|
1486
|
+
* @see bw.mapScale
|
|
1487
|
+
* @example
|
|
1488
|
+
* bw.clip(150, 0, 100) // => 100
|
|
1489
|
+
* bw.clip(-5, 0, 100) // => 0
|
|
1490
|
+
* bw.clip(50, 0, 100) // => 50
|
|
1491
|
+
*/
|
|
1492
|
+
bw.clip = function(value, min, max) {
|
|
1493
|
+
return Math.max(min, Math.min(max, value));
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* DOM selection helper that always returns an array (browser only).
|
|
1498
|
+
*
|
|
1499
|
+
* Wraps `querySelectorAll` and normalizes the result to a plain Array
|
|
1500
|
+
* so you can use `.map()`, `.filter()`, etc. directly. Accepts CSS selectors,
|
|
1501
|
+
* single elements, NodeLists, or arrays.
|
|
1502
|
+
*
|
|
1503
|
+
* @param {string|Element|Array} selector - CSS selector, element, or array
|
|
1504
|
+
* @returns {Array} Array of DOM elements
|
|
1505
|
+
* @category DOM Selection
|
|
1506
|
+
* @example
|
|
1507
|
+
* bw.$('.card') // => [div.card, div.card, ...]
|
|
1508
|
+
* bw.$(myElement) // => [myElement]
|
|
1509
|
+
* bw.$('.card').map(el => el.textContent)
|
|
1510
|
+
*/
|
|
1511
|
+
if (bw._isBrowser) {
|
|
1512
|
+
bw.$ = function(selector) {
|
|
1513
|
+
if (!selector) return [];
|
|
1514
|
+
|
|
1515
|
+
// Already an array
|
|
1516
|
+
if (Array.isArray(selector)) return selector;
|
|
1517
|
+
|
|
1518
|
+
// Single element
|
|
1519
|
+
if (selector.nodeType) return [selector];
|
|
1520
|
+
|
|
1521
|
+
// NodeList or HTMLCollection
|
|
1522
|
+
if (selector.length !== undefined && typeof selector !== 'string') {
|
|
1523
|
+
return Array.from(selector);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// CSS selector string
|
|
1527
|
+
if (typeof selector === 'string') {
|
|
1528
|
+
return Array.from(document.querySelectorAll(selector));
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
return [];
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
// Convenience single element selector
|
|
1535
|
+
bw.$.one = function(selector) {
|
|
1536
|
+
return bw.$(selector)[0] || null;
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Load the built-in Bootstrap-inspired default stylesheet.
|
|
1542
|
+
*
|
|
1543
|
+
* Injects bitwrench's batteries-included CSS (buttons, cards, grids, forms,
|
|
1544
|
+
* alerts, badges, nav, tabs, etc.) into the document head. Call once at app startup.
|
|
1545
|
+
* Returns null in Node.js (no DOM).
|
|
1546
|
+
*
|
|
1547
|
+
* @param {Object} [options] - Style loading options
|
|
1548
|
+
* @param {boolean} [options.minify=true] - Minify the CSS output
|
|
1549
|
+
* @returns {Element|null} Style element if in browser, null in Node.js
|
|
1550
|
+
* @category CSS & Styling
|
|
1551
|
+
* @see bw.setTheme
|
|
1552
|
+
* @see bw.toggleDarkMode
|
|
1553
|
+
* @example
|
|
1554
|
+
* bw.loadDefaultStyles(); // inject all default CSS
|
|
1555
|
+
*/
|
|
1556
|
+
bw.loadDefaultStyles = function(options = {}) {
|
|
1557
|
+
const { minify = true, palette } = options;
|
|
1558
|
+
|
|
1559
|
+
// 1. Inject structural CSS (layout, sizing — never changes with theme)
|
|
1560
|
+
if (bw._isBrowser) {
|
|
1561
|
+
var structuralCSS = bw.css(getStructuralStyles());
|
|
1562
|
+
bw.injectCSS(structuralCSS, { id: 'bw-structural', append: false, minify: minify });
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// 2. Inject cosmetic CSS via generateTheme (colors, shadows, radii)
|
|
1566
|
+
var paletteConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, palette || {});
|
|
1567
|
+
var result = bw.generateTheme('', Object.assign({}, paletteConfig, { inject: true }));
|
|
1568
|
+
return result;
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Get the current theme configuration as a deep copy.
|
|
1573
|
+
*
|
|
1574
|
+
* @returns {Object} Theme object with colors, fonts, spacing, etc.
|
|
1575
|
+
* @category CSS & Styling
|
|
1576
|
+
* @see bw.setTheme
|
|
1577
|
+
*/
|
|
1578
|
+
bw.getTheme = function() {
|
|
1579
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
1580
|
+
console.warn('bw.getTheme() is deprecated. Use bw.generateTheme() instead.');
|
|
1581
|
+
}
|
|
1582
|
+
return JSON.parse(JSON.stringify(theme));
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Set theme overrides and optionally re-inject CSS custom properties.
|
|
1587
|
+
*
|
|
1588
|
+
* Merges your overrides into the current theme and updates `--bw-*` CSS
|
|
1589
|
+
* custom properties on `<html>` so all components pick up the changes live.
|
|
1590
|
+
*
|
|
1591
|
+
* @param {Object} overrides - Partial theme object to merge (e.g. { colors: { primary: '#ff0000' } })
|
|
1592
|
+
* @param {Object} [options] - Options
|
|
1593
|
+
* @param {boolean} [options.inject=true] - Whether to re-inject CSS (browser only)
|
|
1594
|
+
* @returns {Object} Updated theme
|
|
1595
|
+
* @category CSS & Styling
|
|
1596
|
+
* @see bw.getTheme
|
|
1597
|
+
* @see bw.loadDefaultStyles
|
|
1598
|
+
* @example
|
|
1599
|
+
* bw.setTheme({ colors: { primary: '#ff6600' } });
|
|
1600
|
+
*/
|
|
1601
|
+
bw.setTheme = function(overrides, options = {}) {
|
|
1602
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
1603
|
+
console.warn('bw.setTheme() is deprecated. Use bw.generateTheme() instead.');
|
|
1604
|
+
}
|
|
1605
|
+
const { inject = true } = options;
|
|
1606
|
+
updateTheme(overrides);
|
|
1607
|
+
|
|
1608
|
+
// Update CSS custom properties if colors changed and we're in browser
|
|
1609
|
+
if (inject && bw._isBrowser && overrides.colors) {
|
|
1610
|
+
const root = document.documentElement;
|
|
1611
|
+
for (const [name, value] of Object.entries(overrides.colors)) {
|
|
1612
|
+
root.style.setProperty('--bw-' + name, value);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
return bw.getTheme();
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Toggle dark mode on/off.
|
|
1621
|
+
*
|
|
1622
|
+
* Adds/removes the `bw-dark` class on `<html>` and injects dark mode CSS
|
|
1623
|
+
* overrides. Pass `true`/`false` to force a mode, or omit to toggle.
|
|
1624
|
+
*
|
|
1625
|
+
* @param {boolean} [force] - Force dark (true) or light (false). Omit to toggle.
|
|
1626
|
+
* @returns {boolean} Whether dark mode is now active
|
|
1627
|
+
* @category CSS & Styling
|
|
1628
|
+
* @see bw.setTheme
|
|
1629
|
+
* @example
|
|
1630
|
+
* bw.toggleDarkMode(); // toggle
|
|
1631
|
+
* bw.toggleDarkMode(true); // force dark
|
|
1632
|
+
* bw.toggleDarkMode(false); // force light
|
|
1633
|
+
*/
|
|
1634
|
+
bw.toggleDarkMode = function(force) {
|
|
1635
|
+
const isDark = force !== undefined ? force : !theme.darkMode;
|
|
1636
|
+
theme.darkMode = isDark;
|
|
1637
|
+
|
|
1638
|
+
if (bw._isBrowser) {
|
|
1639
|
+
const root = document.documentElement;
|
|
1640
|
+
if (isDark) {
|
|
1641
|
+
root.classList.add('bw-dark');
|
|
1642
|
+
// Generate palette-aware dark mode CSS, or fall back to default
|
|
1643
|
+
var palette = bw._activePalette || derivePalette(DEFAULT_PALETTE_CONFIG);
|
|
1644
|
+
var darkRules = generateDarkModeCSS(palette);
|
|
1645
|
+
var darkCSS = bw.css(darkRules);
|
|
1646
|
+
|
|
1647
|
+
// Remove existing dark styles to allow regeneration
|
|
1648
|
+
var existing = document.getElementById('bw-dark-styles');
|
|
1649
|
+
if (existing) existing.remove();
|
|
1650
|
+
|
|
1651
|
+
var styleEl = document.createElement('style');
|
|
1652
|
+
styleEl.id = 'bw-dark-styles';
|
|
1653
|
+
styleEl.textContent = darkCSS;
|
|
1654
|
+
document.head.appendChild(styleEl);
|
|
1655
|
+
} else {
|
|
1656
|
+
root.classList.remove('bw-dark');
|
|
1657
|
+
// Remove dark mode styles when switching to light
|
|
1658
|
+
var darkEl = document.getElementById('bw-dark-styles');
|
|
1659
|
+
if (darkEl) darkEl.remove();
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
return isDark;
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Generate a complete, scoped theme from seed colors.
|
|
1668
|
+
*
|
|
1669
|
+
* Produces CSS for all themed components (buttons, alerts, badges, cards,
|
|
1670
|
+
* forms, nav, tables, tabs, list groups, pagination, progress, hero, utilities)
|
|
1671
|
+
* scoped under `.name` class. Multiple themes can coexist in the stylesheet.
|
|
1672
|
+
* Swap themes by changing the class on a container element.
|
|
1673
|
+
*
|
|
1674
|
+
* @param {string} name - CSS scope class (e.g. 'ocean'). Empty string = unscoped global.
|
|
1675
|
+
* @param {Object} config - Theme configuration
|
|
1676
|
+
* @param {string} config.primary - Primary brand color hex
|
|
1677
|
+
* @param {string} config.secondary - Secondary color hex
|
|
1678
|
+
* @param {string} [config.tertiary] - Tertiary/accent color hex (defaults to primary)
|
|
1679
|
+
* @param {string} [config.success='#198754'] - Success color hex
|
|
1680
|
+
* @param {string} [config.danger='#dc3545'] - Danger color hex
|
|
1681
|
+
* @param {string} [config.warning='#ffc107'] - Warning color hex
|
|
1682
|
+
* @param {string} [config.info='#0dcaf0'] - Info color hex
|
|
1683
|
+
* @param {string} [config.light='#f8f9fa'] - Light color hex
|
|
1684
|
+
* @param {string} [config.dark='#212529'] - Dark color hex
|
|
1685
|
+
* @param {string} [config.spacing='normal'] - 'compact' | 'normal' | 'spacious'
|
|
1686
|
+
* @param {string} [config.radius='md'] - 'none' | 'sm' | 'md' | 'lg' | 'pill'
|
|
1687
|
+
* @param {number} [config.fontSize=1.0] - Base font size scale factor
|
|
1688
|
+
* @param {boolean} [config.inject=true] - Inject into DOM (browser only)
|
|
1689
|
+
* @returns {Object} { css, palette, name }
|
|
1690
|
+
* @category CSS & Styling
|
|
1691
|
+
* @see bw.loadDefaultStyles
|
|
1692
|
+
* @example
|
|
1693
|
+
* // Generate and inject an ocean theme
|
|
1694
|
+
* bw.generateTheme('ocean', {
|
|
1695
|
+
* primary: '#0077b6',
|
|
1696
|
+
* secondary: '#90e0ef',
|
|
1697
|
+
* tertiary: '#00b4d8'
|
|
1698
|
+
* });
|
|
1699
|
+
*
|
|
1700
|
+
* // Apply to a container
|
|
1701
|
+
* document.getElementById('app').classList.add('ocean');
|
|
1702
|
+
*
|
|
1703
|
+
* // Generate CSS for static export (Node.js)
|
|
1704
|
+
* var result = bw.generateTheme('sunset', {
|
|
1705
|
+
* primary: '#e76f51',
|
|
1706
|
+
* secondary: '#264653',
|
|
1707
|
+
* tertiary: '#e9c46a',
|
|
1708
|
+
* inject: false
|
|
1709
|
+
* });
|
|
1710
|
+
* fs.writeFileSync('sunset.css', result.css);
|
|
1711
|
+
*/
|
|
1712
|
+
bw.generateTheme = function(name, config) {
|
|
1713
|
+
if (!config || !config.primary || !config.secondary) {
|
|
1714
|
+
throw new Error('bw.generateTheme requires config.primary and config.secondary');
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Merge with defaults; if user didn't supply tertiary, default to their primary
|
|
1718
|
+
var fullConfig = Object.assign({}, DEFAULT_PALETTE_CONFIG, config);
|
|
1719
|
+
if (!config.tertiary) fullConfig.tertiary = fullConfig.primary;
|
|
1720
|
+
|
|
1721
|
+
// Derive palette
|
|
1722
|
+
var palette = derivePalette(fullConfig);
|
|
1723
|
+
|
|
1724
|
+
// Store active palette for dark mode
|
|
1725
|
+
bw._activePalette = palette;
|
|
1726
|
+
|
|
1727
|
+
// Resolve layout
|
|
1728
|
+
var layout = resolveLayout(fullConfig);
|
|
1729
|
+
|
|
1730
|
+
// Generate themed CSS rules
|
|
1731
|
+
var themedRules = generateThemedCSS(name, palette, layout);
|
|
1732
|
+
|
|
1733
|
+
// Add underscore aliases
|
|
1734
|
+
var aliasedRules = addUnderscoreAliases(themedRules);
|
|
1735
|
+
|
|
1736
|
+
// Convert to CSS string
|
|
1737
|
+
var cssStr = bw.css(aliasedRules);
|
|
1738
|
+
|
|
1739
|
+
// Inject into DOM if requested and in browser
|
|
1740
|
+
var shouldInject = config.inject !== false;
|
|
1741
|
+
if (shouldInject && bw._isBrowser) {
|
|
1742
|
+
var styleId = name ? 'bw-theme-' + name : 'bw-theme-default';
|
|
1743
|
+
bw.injectCSS(cssStr, { id: styleId, append: false });
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Update bw.u color entries to reflect the palette
|
|
1747
|
+
if (!name) {
|
|
1748
|
+
bw.u.bgTeal = { background: palette.primary.base, color: palette.primary.textOn };
|
|
1749
|
+
bw.u.textTeal = { color: palette.primary.base };
|
|
1750
|
+
bw.u.bgWhite = { background: '#ffffff' };
|
|
1751
|
+
bw.u.textWhite = { color: '#ffffff' };
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
return { css: cssStr, palette: palette, name: name };
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
// Expose color utility functions on bw namespace
|
|
1758
|
+
bw.hexToHsl = hexToHsl;
|
|
1759
|
+
bw.hslToHex = hslToHex;
|
|
1760
|
+
bw.adjustLightness = adjustLightness;
|
|
1761
|
+
bw.mixColor = mixColor;
|
|
1762
|
+
bw.relativeLuminance = relativeLuminance;
|
|
1763
|
+
bw.textOnColor = textOnColor;
|
|
1764
|
+
bw.deriveShades = deriveShades;
|
|
1765
|
+
bw.derivePalette = derivePalette;
|
|
1766
|
+
|
|
1767
|
+
// Expose layout and theme presets
|
|
1768
|
+
bw.SPACING_PRESETS = SPACING_PRESETS;
|
|
1769
|
+
bw.RADIUS_PRESETS = RADIUS_PRESETS;
|
|
1770
|
+
bw.DEFAULT_PALETTE_CONFIG = DEFAULT_PALETTE_CONFIG;
|
|
1771
|
+
bw.THEME_PRESETS = THEME_PRESETS;
|
|
1772
|
+
|
|
1773
|
+
// ===================================================================================
|
|
1774
|
+
// Legacy v1 Functions - Useful utilities retained from bitwrench v1
|
|
1775
|
+
// ===================================================================================
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Use a dictionary as a switch statement, with support for function values.
|
|
1779
|
+
*
|
|
1780
|
+
* Looks up `x` in `choices`. If the value is a function, calls it with `x` as argument.
|
|
1781
|
+
* Returns `def` if the key is not found.
|
|
1782
|
+
*
|
|
1783
|
+
* @param {*} x - Key to look up
|
|
1784
|
+
* @param {Object} choices - Dictionary of choices (values can be functions)
|
|
1785
|
+
* @param {*} def - Default value if key not found
|
|
1786
|
+
* @returns {*} Value or function result
|
|
1787
|
+
* @category Array Utilities
|
|
1788
|
+
* @example
|
|
1789
|
+
* var colors = { red: 1, blue: 2, aqua: function(z) { return z + 'marine'; } };
|
|
1790
|
+
* bw.choice('red', colors, '0') // => 1
|
|
1791
|
+
* bw.choice('aqua', colors) // => 'aquamarine'
|
|
1792
|
+
* bw.choice('pink', colors, 'n/a') // => 'n/a'
|
|
1793
|
+
*/
|
|
1794
|
+
bw.choice = function(x, choices, def) {
|
|
1795
|
+
const z = (x in choices) ? choices[x] : def;
|
|
1796
|
+
return bw.typeOf(z) === "function" ? z(x) : z;
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Return unique elements of an array (preserves first occurrence order).
|
|
1801
|
+
*
|
|
1802
|
+
* @param {Array} x - Input array
|
|
1803
|
+
* @returns {Array} Array with unique elements
|
|
1804
|
+
* @category Array Utilities
|
|
1805
|
+
* @example
|
|
1806
|
+
* bw.arrayUniq([1, 2, 2, 3, 1]) // => [1, 2, 3]
|
|
1807
|
+
*/
|
|
1808
|
+
bw.arrayUniq = function(x) {
|
|
1809
|
+
if (bw.typeOf(x) !== "array") return [];
|
|
1810
|
+
return x.filter((v, i, arr) => arr.indexOf(v) === i);
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Return the intersection of two arrays (elements present in both).
|
|
1815
|
+
*
|
|
1816
|
+
* @param {Array} a - First array
|
|
1817
|
+
* @param {Array} b - Second array
|
|
1818
|
+
* @returns {Array} Unique elements found in both a and b
|
|
1819
|
+
* @category Array Utilities
|
|
1820
|
+
* @see bw.arrayBNotInA
|
|
1821
|
+
* @example
|
|
1822
|
+
* bw.arrayBinA([1, 2, 3], [2, 3, 4]) // => [2, 3]
|
|
1823
|
+
*/
|
|
1824
|
+
bw.arrayBinA = function(a, b) {
|
|
1825
|
+
if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
|
|
1826
|
+
return bw.arrayUniq(a.filter(n => b.indexOf(n) !== -1));
|
|
1827
|
+
};
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Return elements of b that are not present in a (set difference).
|
|
1831
|
+
*
|
|
1832
|
+
* @param {Array} a - First array (the "exclude" set)
|
|
1833
|
+
* @param {Array} b - Second array (source of results)
|
|
1834
|
+
* @returns {Array} Unique elements in b but not in a
|
|
1835
|
+
* @category Array Utilities
|
|
1836
|
+
* @see bw.arrayBinA
|
|
1837
|
+
* @example
|
|
1838
|
+
* bw.arrayBNotInA([1, 2, 3], [2, 3, 4, 5]) // => [4, 5]
|
|
1839
|
+
*/
|
|
1840
|
+
bw.arrayBNotInA = function(a, b) {
|
|
1841
|
+
if (bw.typeOf(a) !== "array" || bw.typeOf(b) !== "array") return [];
|
|
1842
|
+
return bw.arrayUniq(b.filter(n => a.indexOf(n) < 0));
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Interpolate between an array of colors based on a value in a range.
|
|
1847
|
+
*
|
|
1848
|
+
* Maps a value from [in0..in1] across a gradient of colors, smoothly blending
|
|
1849
|
+
* between adjacent stops. Useful for heatmaps, gauges, and data visualization.
|
|
1850
|
+
*
|
|
1851
|
+
* @param {number} x - Value to interpolate
|
|
1852
|
+
* @param {number} in0 - Input range start
|
|
1853
|
+
* @param {number} in1 - Input range end
|
|
1854
|
+
* @param {Array} colors - Array of CSS color strings to interpolate between
|
|
1855
|
+
* @param {number} [stretch] - Exponential scaling factor (1 = linear)
|
|
1856
|
+
* @returns {Array} Interpolated color as [r, g, b, a, "rgb"]
|
|
1857
|
+
* @category Color
|
|
1858
|
+
* @see bw.colorParse
|
|
1859
|
+
* @see bw.mapScale
|
|
1860
|
+
* @example
|
|
1861
|
+
* bw.colorInterp(50, 0, 100, ['#ff0000', '#00ff00'])
|
|
1862
|
+
* // => [128, 128, 0, 255, "rgb"] (yellow midpoint)
|
|
1863
|
+
*/
|
|
1864
|
+
bw.colorInterp = function(x, in0, in1, colors, stretch) {
|
|
1865
|
+
let c = Array.isArray(colors) ? colors : ["#000", "#fff"];
|
|
1866
|
+
c = c.length === 0 ? ["#000", "#fff"] : c;
|
|
1867
|
+
if (c.length === 1) return c[0];
|
|
1868
|
+
|
|
1869
|
+
// Convert all colors to RGB format
|
|
1870
|
+
c = c.map(col => bw.colorParse(col));
|
|
1871
|
+
|
|
1872
|
+
const a = bw.mapScale(x, in0, in1, 0, c.length - 1, { clip: true, expScale: stretch });
|
|
1873
|
+
const i = bw.clip(Math.floor(a), 0, c.length - 2);
|
|
1874
|
+
const r = a - i;
|
|
1875
|
+
|
|
1876
|
+
const interp = (idx) => bw.mapScale(r, 0, 1, c[i][idx], c[i + 1][idx], { clip: true });
|
|
1877
|
+
return [interp(0), interp(1), interp(2), interp(3), "rgb"];
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Convert an HSL color to RGB.
|
|
1882
|
+
*
|
|
1883
|
+
* Accepts individual h, s, l values or a bitwrench color array [h, s, l, a, "hsl"].
|
|
1884
|
+
*
|
|
1885
|
+
* @param {number|Array} h - Hue [0..360] or [h,s,l,a,"hsl"] array
|
|
1886
|
+
* @param {number} s - Saturation [0..100]
|
|
1887
|
+
* @param {number} l - Lightness [0..100]
|
|
1888
|
+
* @param {number} [a=255] - Alpha [0..255]
|
|
1889
|
+
* @param {boolean} [rnd=true] - Round results to integers
|
|
1890
|
+
* @returns {Array} RGB as [r, g, b, a, "rgb"]
|
|
1891
|
+
* @category Color
|
|
1892
|
+
* @see bw.colorRgbToHsl
|
|
1893
|
+
* @example
|
|
1894
|
+
* bw.colorHslToRgb(0, 100, 50) // => [255, 0, 0, 255, "rgb"]
|
|
1895
|
+
* bw.colorHslToRgb(120, 100, 50) // => [0, 255, 0, 255, "rgb"]
|
|
1896
|
+
*/
|
|
1897
|
+
bw.colorHslToRgb = function(h, s, l, a = 255, rnd = true) {
|
|
1898
|
+
if (bw.typeOf(h) === "array") {
|
|
1899
|
+
s = h[1]; l = h[2]; a = h[3]; h = h[0];
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
const hNorm = h / 360;
|
|
1903
|
+
const sNorm = s / 100;
|
|
1904
|
+
const lNorm = l / 100;
|
|
1905
|
+
|
|
1906
|
+
let r, g, b;
|
|
1907
|
+
|
|
1908
|
+
if (sNorm === 0) {
|
|
1909
|
+
r = g = b = lNorm * 255;
|
|
1910
|
+
} else {
|
|
1911
|
+
const hue2rgb = (p, q, t) => {
|
|
1912
|
+
if (t < 0) t += 1;
|
|
1913
|
+
if (t > 1) t -= 1;
|
|
1914
|
+
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
1915
|
+
if (t < 1/2) return q;
|
|
1916
|
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
1917
|
+
return p;
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
const q = lNorm < 0.5 ? lNorm * (1 + sNorm) : lNorm + sNorm - lNorm * sNorm;
|
|
1921
|
+
const p = 2 * lNorm - q;
|
|
1922
|
+
|
|
1923
|
+
r = hue2rgb(p, q, hNorm + 1/3) * 255;
|
|
1924
|
+
g = hue2rgb(p, q, hNorm) * 255;
|
|
1925
|
+
b = hue2rgb(p, q, hNorm - 1/3) * 255;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (rnd) {
|
|
1929
|
+
r = Math.round(r);
|
|
1930
|
+
g = Math.round(g);
|
|
1931
|
+
b = Math.round(b);
|
|
1932
|
+
a = Math.round(a);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
return [r, g, b, a, "rgb"];
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Convert an RGB color to HSL.
|
|
1940
|
+
*
|
|
1941
|
+
* Accepts individual r, g, b values or a bitwrench color array [r, g, b, a, "rgb"].
|
|
1942
|
+
*
|
|
1943
|
+
* @param {number|Array} r - Red [0..255] or [r,g,b,a,"rgb"] array
|
|
1944
|
+
* @param {number} g - Green [0..255]
|
|
1945
|
+
* @param {number} b - Blue [0..255]
|
|
1946
|
+
* @param {number} [a=255] - Alpha [0..255]
|
|
1947
|
+
* @param {boolean} [rnd=true] - Round results to integers
|
|
1948
|
+
* @returns {Array} HSL as [h, s, l, a, "hsl"]
|
|
1949
|
+
* @category Color
|
|
1950
|
+
* @see bw.colorHslToRgb
|
|
1951
|
+
* @example
|
|
1952
|
+
* bw.colorRgbToHsl(255, 0, 0) // => [0, 100, 50, 255, "hsl"]
|
|
1953
|
+
* bw.colorRgbToHsl(0, 0, 255) // => [240, 100, 50, 255, "hsl"]
|
|
1954
|
+
*/
|
|
1955
|
+
bw.colorRgbToHsl = function(r, g, b, a = 255, rnd = true) {
|
|
1956
|
+
if (bw.typeOf(r) === "array") {
|
|
1957
|
+
g = r[1]; b = r[2]; a = r[3]; r = r[0];
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
r /= 255;
|
|
1961
|
+
g /= 255;
|
|
1962
|
+
b /= 255;
|
|
1963
|
+
|
|
1964
|
+
const max = Math.max(r, g, b);
|
|
1965
|
+
const min = Math.min(r, g, b);
|
|
1966
|
+
let h, s, l = (max + min) / 2;
|
|
1967
|
+
|
|
1968
|
+
if (max === min) {
|
|
1969
|
+
h = s = 0; // achromatic
|
|
1970
|
+
} else {
|
|
1971
|
+
const d = max - min;
|
|
1972
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
1973
|
+
|
|
1974
|
+
switch (max) {
|
|
1975
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
1976
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
1977
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
h *= 360;
|
|
1982
|
+
s *= 100;
|
|
1983
|
+
l *= 100;
|
|
1984
|
+
|
|
1985
|
+
if (rnd) {
|
|
1986
|
+
h = Math.round(h);
|
|
1987
|
+
s = Math.round(s);
|
|
1988
|
+
l = Math.round(l);
|
|
1989
|
+
a = Math.round(a);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
return [h, s, l, a, "hsl"];
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* Parse a CSS color string into bitwrench's internal array format.
|
|
1997
|
+
*
|
|
1998
|
+
* Supports hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), and hsla().
|
|
1999
|
+
* Also accepts existing bitwrench color arrays (pass-through).
|
|
2000
|
+
*
|
|
2001
|
+
* @param {string|Array} s - CSS color string (e.g. "#ff0000", "rgb(255,0,0)") or color array
|
|
2002
|
+
* @param {number} [defAlpha=255] - Default alpha value
|
|
2003
|
+
* @returns {Array} Color as [c0, c1, c2, a, "rgb"|"hsl"]
|
|
2004
|
+
* @category Color
|
|
2005
|
+
* @see bw.colorInterp
|
|
2006
|
+
* @example
|
|
2007
|
+
* bw.colorParse('#ff0000') // => [255, 0, 0, 255, "rgb"]
|
|
2008
|
+
* bw.colorParse('rgb(0,128,255)') // => [0, 128, 255, 255, "rgb"]
|
|
2009
|
+
*/
|
|
2010
|
+
bw.colorParse = function(s, defAlpha = 255) {
|
|
2011
|
+
let r = [0, 0, 0, defAlpha, "rgb"]; // default return
|
|
2012
|
+
|
|
2013
|
+
if (bw.typeOf(s) === "array") {
|
|
2014
|
+
// Handle bitwrench color array
|
|
2015
|
+
const df = [0, 0, 0, 255, "rgb"];
|
|
2016
|
+
for (let p = 0; p < s.length && p < df.length; p++) {
|
|
2017
|
+
df[p] = s[p];
|
|
2018
|
+
}
|
|
2019
|
+
return df;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
s = String(s).replace(/\s/g, "");
|
|
2023
|
+
|
|
2024
|
+
// Handle hex colors
|
|
2025
|
+
if (s[0] === "#") {
|
|
2026
|
+
const hex = s.slice(1);
|
|
2027
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
2028
|
+
// #rgb or #rgba
|
|
2029
|
+
for (let i = 0; i < hex.length; i++) {
|
|
2030
|
+
r[i] = parseInt(hex[i] + hex[i], 16);
|
|
2031
|
+
}
|
|
2032
|
+
} else if (hex.length === 6 || hex.length === 8) {
|
|
2033
|
+
// #rrggbb or #rrggbbaa
|
|
2034
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
2035
|
+
r[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
} else {
|
|
2039
|
+
// Handle rgb() rgba() hsl() hsla()
|
|
2040
|
+
const match = s.match(/^(rgb|hsl)a?\(([^)]+)\)$/i);
|
|
2041
|
+
if (match) {
|
|
2042
|
+
const type = match[1].toLowerCase();
|
|
2043
|
+
const values = match[2].split(",").map(v => parseFloat(v));
|
|
2044
|
+
|
|
2045
|
+
if (type === "rgb") {
|
|
2046
|
+
r[0] = values[0] || 0;
|
|
2047
|
+
r[1] = values[1] || 0;
|
|
2048
|
+
r[2] = values[2] || 0;
|
|
2049
|
+
r[3] = values[3] !== undefined ? values[3] * 255 : defAlpha;
|
|
2050
|
+
r[4] = "rgb";
|
|
2051
|
+
} else if (type === "hsl") {
|
|
2052
|
+
const rgb = bw.colorHslToRgb(values[0] || 0, values[1] || 0, values[2] || 0,
|
|
2053
|
+
values[3] !== undefined ? values[3] * 255 : defAlpha);
|
|
2054
|
+
return rgb;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
return r;
|
|
2060
|
+
};
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Set a browser cookie with expiration and options.
|
|
2064
|
+
*
|
|
2065
|
+
* @param {string} cname - Cookie name
|
|
2066
|
+
* @param {string} cvalue - Cookie value
|
|
2067
|
+
* @param {number} exdays - Expiration in days from now
|
|
2068
|
+
* @param {Object} [options] - Additional cookie options
|
|
2069
|
+
* @param {string} [options.path] - Cookie path
|
|
2070
|
+
* @param {string} [options.domain] - Cookie domain
|
|
2071
|
+
* @param {boolean} [options.secure] - Secure flag
|
|
2072
|
+
* @param {string} [options.sameSite] - SameSite attribute
|
|
2073
|
+
* @category Browser Utilities
|
|
2074
|
+
* @see bw.getCookie
|
|
2075
|
+
*/
|
|
2076
|
+
bw.setCookie = function(cname, cvalue, exdays, options = {}) {
|
|
2077
|
+
if (!bw._isBrowser) return;
|
|
2078
|
+
|
|
2079
|
+
const d = new Date();
|
|
2080
|
+
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
|
|
2081
|
+
|
|
2082
|
+
let cookie = `${cname}=${cvalue}; expires=${d.toUTCString()}`;
|
|
2083
|
+
|
|
2084
|
+
// Add additional options
|
|
2085
|
+
if (options.path) cookie += `; path=${options.path}`;
|
|
2086
|
+
if (options.domain) cookie += `; domain=${options.domain}`;
|
|
2087
|
+
if (options.secure) cookie += '; secure';
|
|
2088
|
+
if (options.sameSite) cookie += `; samesite=${options.sameSite}`;
|
|
2089
|
+
|
|
2090
|
+
document.cookie = cookie;
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
/**
|
|
2094
|
+
* Get a browser cookie value by name.
|
|
2095
|
+
*
|
|
2096
|
+
* @param {string} cname - Cookie name
|
|
2097
|
+
* @param {*} defaultValue - Default value if cookie not found
|
|
2098
|
+
* @returns {*} Cookie value or default
|
|
2099
|
+
* @category Browser Utilities
|
|
2100
|
+
* @see bw.setCookie
|
|
2101
|
+
*/
|
|
2102
|
+
bw.getCookie = function(cname, defaultValue) {
|
|
2103
|
+
if (!bw._isBrowser) return defaultValue;
|
|
2104
|
+
|
|
2105
|
+
const name = cname + "=";
|
|
2106
|
+
const ca = document.cookie.split(";");
|
|
2107
|
+
|
|
2108
|
+
for (let i = 0; i < ca.length; i++) {
|
|
2109
|
+
let c = ca[i];
|
|
2110
|
+
while (c.charAt(0) === " ") c = c.substring(1);
|
|
2111
|
+
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
return defaultValue;
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* Get a URL query parameter value from the current page URL.
|
|
2119
|
+
*
|
|
2120
|
+
* Pass no key to get all parameters as an object. Returns `true` for
|
|
2121
|
+
* present-but-empty parameters.
|
|
2122
|
+
*
|
|
2123
|
+
* @param {string} [key] - Parameter name (omit to get all params)
|
|
2124
|
+
* @param {*} defaultValue - Default if not found
|
|
2125
|
+
* @returns {*} Parameter value, true (present but empty), or default
|
|
2126
|
+
* @category Browser Utilities
|
|
2127
|
+
*/
|
|
2128
|
+
bw.getURLParam = function(key, defaultValue) {
|
|
2129
|
+
if (!bw._isBrowser || typeof window !== "object") return defaultValue;
|
|
2130
|
+
|
|
2131
|
+
try {
|
|
2132
|
+
const params = new URLSearchParams(window.location.search);
|
|
2133
|
+
|
|
2134
|
+
if (!key) {
|
|
2135
|
+
// Return all params as object
|
|
2136
|
+
const result = {};
|
|
2137
|
+
for (const [k, v] of params) {
|
|
2138
|
+
result[k] = v || true;
|
|
2139
|
+
}
|
|
2140
|
+
return result;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
return params.has(key) ? (params.get(key) || true) : defaultValue;
|
|
2144
|
+
} catch (e) {
|
|
2145
|
+
return defaultValue;
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Create an HTML table string from a 2D data array.
|
|
2151
|
+
*
|
|
2152
|
+
* Legacy v1 API — returns an HTML string, not a TACO. First row is used
|
|
2153
|
+
* as headers by default. For TACO-based tables, use `bw.makeTable()` instead.
|
|
2154
|
+
*
|
|
2155
|
+
* @param {Array} data - 2D array of table data
|
|
2156
|
+
* @param {Object} [opts] - Table options
|
|
2157
|
+
* @param {boolean} [opts.useFirstRowAsHeaders=true] - Use first row as headers
|
|
2158
|
+
* @param {string} [opts.caption] - Table caption
|
|
2159
|
+
* @returns {string} HTML table string
|
|
2160
|
+
* @category Legacy (v1)
|
|
2161
|
+
* @see bw.makeTable
|
|
2162
|
+
*/
|
|
2163
|
+
bw.htmlTable = function(data, opts = {}) {
|
|
2164
|
+
if (bw.typeOf(data) !== "array" || data.length < 1) return "";
|
|
2165
|
+
|
|
2166
|
+
const dopts = {
|
|
2167
|
+
useFirstRowAsHeaders: true,
|
|
2168
|
+
caption: null,
|
|
2169
|
+
atr: { class: "table" },
|
|
2170
|
+
thead_atr: {},
|
|
2171
|
+
th_atr: {},
|
|
2172
|
+
tbody_atr: {},
|
|
2173
|
+
tr_atr: {},
|
|
2174
|
+
td_atr: {}
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
Object.assign(dopts, opts);
|
|
2178
|
+
|
|
2179
|
+
let html = `<table${bw._attrsToStr(dopts.atr)}>`;
|
|
2180
|
+
|
|
2181
|
+
if (dopts.caption) {
|
|
2182
|
+
html += `<caption>${bw.escapeHTML(dopts.caption)}</caption>`;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
let startRow = 0;
|
|
2186
|
+
|
|
2187
|
+
// Handle header row
|
|
2188
|
+
if (dopts.useFirstRowAsHeaders && data.length > 0) {
|
|
2189
|
+
html += `<thead${bw._attrsToStr(dopts.thead_atr)}>`;
|
|
2190
|
+
html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
|
|
2191
|
+
|
|
2192
|
+
data[0].forEach(cell => {
|
|
2193
|
+
html += `<th${bw._attrsToStr(dopts.th_atr)}>${bw.escapeHTML(String(cell))}</th>`;
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
html += "</tr></thead>";
|
|
2197
|
+
startRow = 1;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Body rows
|
|
2201
|
+
if (data.length > startRow) {
|
|
2202
|
+
html += `<tbody${bw._attrsToStr(dopts.tbody_atr)}>`;
|
|
2203
|
+
|
|
2204
|
+
for (let i = startRow; i < data.length; i++) {
|
|
2205
|
+
html += `<tr${bw._attrsToStr(dopts.tr_atr)}>`;
|
|
2206
|
+
|
|
2207
|
+
data[i].forEach(cell => {
|
|
2208
|
+
html += `<td${bw._attrsToStr(dopts.td_atr)}>${bw.escapeHTML(String(cell))}</td>`;
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
html += "</tr>";
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
html += "</tbody>";
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
html += "</table>";
|
|
2218
|
+
|
|
2219
|
+
return html;
|
|
2220
|
+
};
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Convert an attributes object to an HTML attribute string
|
|
2224
|
+
*
|
|
2225
|
+
* Handles boolean attributes (key only), null/undefined/false (skipped),
|
|
2226
|
+
* and regular string values (HTML-escaped). Used internally by bw.htmlTable()
|
|
2227
|
+
* and bw.htmlTabs().
|
|
2228
|
+
*
|
|
2229
|
+
* @param {Object} attrs - Attribute key-value pairs
|
|
2230
|
+
* @returns {string} HTML attribute string with leading space, or empty string
|
|
2231
|
+
* @private
|
|
2232
|
+
*/
|
|
2233
|
+
bw._attrsToStr = function(attrs) {
|
|
2234
|
+
if (!attrs || typeof attrs !== "object") return "";
|
|
2235
|
+
|
|
2236
|
+
let str = "";
|
|
2237
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
2238
|
+
if (value != null && value !== false) {
|
|
2239
|
+
if (value === true) {
|
|
2240
|
+
str += ` ${key}`;
|
|
2241
|
+
} else {
|
|
2242
|
+
str += ` ${key}="${bw.escapeHTML(String(value))}"`;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
return str;
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Create an HTML tabs structure from an array of [title, content] pairs.
|
|
2252
|
+
*
|
|
2253
|
+
* Legacy v1 API — returns an HTML string. For TACO-based tabs,
|
|
2254
|
+
* use `bw.makeTabs()` instead.
|
|
2255
|
+
*
|
|
2256
|
+
* @param {Array} tabData - Array of [title, content] pairs
|
|
2257
|
+
* @param {Object} [opts] - Tab options
|
|
2258
|
+
* @returns {string} HTML tabs string
|
|
2259
|
+
* @category Legacy (v1)
|
|
2260
|
+
* @see bw.makeTabs
|
|
2261
|
+
*/
|
|
2262
|
+
bw.htmlTabs = function(tabData, opts = {}) {
|
|
2263
|
+
if (bw.typeOf(tabData) !== "array" || tabData.length < 1) return "";
|
|
2264
|
+
|
|
2265
|
+
const dopts = {
|
|
2266
|
+
atr: { class: "bw-tab-container" },
|
|
2267
|
+
tab_atr: { class: "bw-tab-item-list" },
|
|
2268
|
+
tabc_atr: { class: "bw-tab-content-list" }
|
|
2269
|
+
};
|
|
2270
|
+
|
|
2271
|
+
Object.assign(dopts, opts);
|
|
2272
|
+
|
|
2273
|
+
// Create tab items
|
|
2274
|
+
const tabItems = tabData.map((tab, idx) => ({
|
|
2275
|
+
t: "li",
|
|
2276
|
+
a: {
|
|
2277
|
+
class: idx === 0 ? "bw-tab-item bw-tab-active" : "bw-tab-item",
|
|
2278
|
+
onclick: "bw.selectTabContent(this)"
|
|
2279
|
+
},
|
|
2280
|
+
c: tab[0]
|
|
2281
|
+
}));
|
|
2282
|
+
|
|
2283
|
+
// Create tab content
|
|
2284
|
+
const tabContent = tabData.map((tab, idx) => ({
|
|
2285
|
+
t: "div",
|
|
2286
|
+
a: { class: idx === 0 ? "bw-tab-content bw-show" : "bw-tab-content" },
|
|
2287
|
+
c: tab[1]
|
|
2288
|
+
}));
|
|
2289
|
+
|
|
2290
|
+
return bw.html({
|
|
2291
|
+
t: "div",
|
|
2292
|
+
a: dopts.atr,
|
|
2293
|
+
c: [
|
|
2294
|
+
{ t: "ul", a: dopts.tab_atr, c: tabItems },
|
|
2295
|
+
{ t: "div", a: dopts.tabc_atr, c: tabContent }
|
|
2296
|
+
]
|
|
2297
|
+
});
|
|
2298
|
+
};
|
|
2299
|
+
|
|
2300
|
+
/**
|
|
2301
|
+
* Tab selection handler — shows the clicked tab's content and hides others.
|
|
2302
|
+
*
|
|
2303
|
+
* Used internally by `bw.htmlTabs()`. You generally don't call this directly.
|
|
2304
|
+
*
|
|
2305
|
+
* @param {Element} tabElement - Clicked tab element
|
|
2306
|
+
* @category Legacy (v1)
|
|
2307
|
+
*/
|
|
2308
|
+
bw.selectTabContent = function(tabElement) {
|
|
2309
|
+
if (!bw._isBrowser || !tabElement) return;
|
|
2310
|
+
|
|
2311
|
+
const container = tabElement.closest(".bw-tab-container");
|
|
2312
|
+
if (!container) return;
|
|
2313
|
+
|
|
2314
|
+
// Remove active class from all tabs
|
|
2315
|
+
container.querySelectorAll(".bw-tab-item").forEach(tab => {
|
|
2316
|
+
tab.classList.remove("bw-tab-active");
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
// Add active to clicked tab
|
|
2320
|
+
tabElement.classList.add("bw-tab-active");
|
|
2321
|
+
|
|
2322
|
+
// Get tab index
|
|
2323
|
+
const tabIndex = Array.from(tabElement.parentElement.children).indexOf(tabElement);
|
|
2324
|
+
|
|
2325
|
+
// Hide all content
|
|
2326
|
+
container.querySelectorAll(".bw-tab-content").forEach(content => {
|
|
2327
|
+
content.classList.remove("bw-show");
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
// Show selected content
|
|
2331
|
+
const contents = container.querySelectorAll(".bw-tab-content");
|
|
2332
|
+
if (contents[tabIndex]) {
|
|
2333
|
+
contents[tabIndex].classList.add("bw-show");
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* Generate Lorem Ipsum placeholder text.
|
|
2339
|
+
*
|
|
2340
|
+
* Useful for prototyping layouts. Generates repeatable text from the standard
|
|
2341
|
+
* Lorem Ipsum passage. Omit numChars for a random length between 25-150 characters.
|
|
2342
|
+
*
|
|
2343
|
+
* @param {number} [numChars] - Number of characters (random 25-150 if not provided)
|
|
2344
|
+
* @param {number} [startSpot] - Starting index in Lorem text (random if undefined)
|
|
2345
|
+
* @param {boolean} [startWithCapitalLetter=true] - Start with a capital letter
|
|
2346
|
+
* @returns {string} Lorem ipsum text
|
|
2347
|
+
* @category Text Generation
|
|
2348
|
+
* @example
|
|
2349
|
+
* bw.loremIpsum(50)
|
|
2350
|
+
* // => "Lorem ipsum dolor sit amet, consectetur adipiscin"
|
|
2351
|
+
*/
|
|
2352
|
+
bw.loremIpsum = function(numChars, startSpot, startWithCapitalLetter = true) {
|
|
2353
|
+
const lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ";
|
|
2354
|
+
|
|
2355
|
+
// If numChars not provided, generate random length between 25-150
|
|
2356
|
+
if (typeof numChars !== "number") {
|
|
2357
|
+
numChars = Math.floor(Math.random() * 125) + 25;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// If startSpot is undefined, randomize it
|
|
2361
|
+
if (startSpot === undefined) {
|
|
2362
|
+
startSpot = Math.floor(Math.random() * lorem.length);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
startSpot = startSpot % lorem.length;
|
|
2366
|
+
|
|
2367
|
+
// Track how many characters we skip to honor numChars
|
|
2368
|
+
let skippedChars = 0;
|
|
2369
|
+
// Move startSpot to the next non-whitespace and non-punctuation character
|
|
2370
|
+
while (lorem[startSpot] === ' ' || /[.,:;!?]/.test(lorem[startSpot])) {
|
|
2371
|
+
startSpot = (startSpot + 1) % lorem.length;
|
|
2372
|
+
skippedChars++;
|
|
2373
|
+
// Prevent infinite loop in case entire lorem is spaces/punctuation
|
|
2374
|
+
if (skippedChars >= lorem.length) {
|
|
2375
|
+
startSpot = 0;
|
|
2376
|
+
skippedChars = 0;
|
|
2377
|
+
break;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
let l = lorem.substring(startSpot) + lorem.substring(0, startSpot);
|
|
2382
|
+
|
|
2383
|
+
let result = "";
|
|
2384
|
+
let remaining = numChars + skippedChars; // Add skipped chars to honor original numChars
|
|
2385
|
+
|
|
2386
|
+
while (remaining > 0) {
|
|
2387
|
+
result += remaining < l.length ? l.substring(0, remaining) : l;
|
|
2388
|
+
remaining -= l.length;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Trim to exact numChars length
|
|
2392
|
+
if (result.length > numChars) {
|
|
2393
|
+
result = result.substring(0, numChars);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// Ensure no trailing space
|
|
2397
|
+
if (result[result.length - 1] === " ") {
|
|
2398
|
+
result = result.substring(0, result.length - 1) + ".";
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Ensure capital letter at start if requested
|
|
2402
|
+
if (startWithCapitalLetter) {
|
|
2403
|
+
let c = result[0].toUpperCase();
|
|
2404
|
+
c = /[A-Z]/.test(c) ? c : "L"; // Use "L" as default if first char isn't a letter
|
|
2405
|
+
result = c + result.substring(1);
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
return result;
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Create a multidimensional array filled with a value or function result.
|
|
2413
|
+
*
|
|
2414
|
+
* If value is a function, it's called for each cell (useful for random data).
|
|
2415
|
+
*
|
|
2416
|
+
* @param {*} value - Value or function to fill array with
|
|
2417
|
+
* @param {number|Array} dims - Dimensions (number for 1D, array for multi-D)
|
|
2418
|
+
* @returns {Array} Multidimensional array
|
|
2419
|
+
* @category Array Utilities
|
|
2420
|
+
* @example
|
|
2421
|
+
* bw.multiArray(0, [4, 5]) // 4x5 array of 0s
|
|
2422
|
+
* bw.multiArray('test', 5) // ['test','test','test','test','test']
|
|
2423
|
+
* bw.multiArray(Math.random, [3, 4]) // 3x4 array of random numbers
|
|
2424
|
+
*/
|
|
2425
|
+
bw.multiArray = function(value, dims) {
|
|
2426
|
+
const v = () => bw.typeOf(value) === "function" ? value() : value;
|
|
2427
|
+
dims = typeof dims === "number" ? [dims] : dims;
|
|
2428
|
+
|
|
2429
|
+
const createArray = (dim) => {
|
|
2430
|
+
if (dim >= dims.length) return v();
|
|
2431
|
+
|
|
2432
|
+
const arr = [];
|
|
2433
|
+
for (let i = 0; i < dims[dim]; i++) {
|
|
2434
|
+
arr[i] = createArray(dim + 1);
|
|
2435
|
+
}
|
|
2436
|
+
return arr;
|
|
2437
|
+
};
|
|
2438
|
+
|
|
2439
|
+
return createArray(0);
|
|
2440
|
+
};
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Natural sort comparison function for use with `Array.sort()`.
|
|
2444
|
+
*
|
|
2445
|
+
* Sorts strings with embedded numbers in human-expected order
|
|
2446
|
+
* (e.g. "file2" before "file10") instead of lexicographic order.
|
|
2447
|
+
*
|
|
2448
|
+
* @param {*} as - First value
|
|
2449
|
+
* @param {*} bs - Second value
|
|
2450
|
+
* @returns {number} Sort order (-1, 0, 1)
|
|
2451
|
+
* @category Array Utilities
|
|
2452
|
+
* @example
|
|
2453
|
+
* ['item10', 'item2', 'item1'].sort(bw.naturalCompare)
|
|
2454
|
+
* // => ['item1', 'item2', 'item10']
|
|
2455
|
+
*/
|
|
2456
|
+
bw.naturalCompare = function(as, bs) {
|
|
2457
|
+
// Handle numbers
|
|
2458
|
+
if (isFinite(as) && isFinite(bs)) {
|
|
2459
|
+
return Math.sign(as - bs);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
const a = String(as).toLowerCase();
|
|
2463
|
+
const b = String(bs).toLowerCase();
|
|
2464
|
+
|
|
2465
|
+
if (a === b) return as > bs ? 1 : 0;
|
|
2466
|
+
|
|
2467
|
+
// If no digits, simple string compare
|
|
2468
|
+
if (!/\d/.test(a) || !/\d/.test(b)) {
|
|
2469
|
+
return a > b ? 1 : -1;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// Split into chunks of digits/non-digits
|
|
2473
|
+
const aParts = a.match(/(\d+|\D+)/g) || [];
|
|
2474
|
+
const bParts = b.match(/(\d+|\D+)/g) || [];
|
|
2475
|
+
|
|
2476
|
+
const len = Math.min(aParts.length, bParts.length);
|
|
2477
|
+
|
|
2478
|
+
for (let i = 0; i < len; i++) {
|
|
2479
|
+
const aPart = aParts[i];
|
|
2480
|
+
const bPart = bParts[i];
|
|
2481
|
+
|
|
2482
|
+
if (aPart !== bPart) {
|
|
2483
|
+
// Both numeric
|
|
2484
|
+
if (/^\d+$/.test(aPart) && /^\d+$/.test(bPart)) {
|
|
2485
|
+
// Handle leading zeros
|
|
2486
|
+
let aNum = aPart;
|
|
2487
|
+
let bNum = bPart;
|
|
2488
|
+
|
|
2489
|
+
if (aPart[0] === "0") aNum = "0." + aPart;
|
|
2490
|
+
if (bPart[0] === "0") bNum = "0." + bPart;
|
|
2491
|
+
|
|
2492
|
+
return parseFloat(aNum) - parseFloat(bNum);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// String comparison
|
|
2496
|
+
return aPart > bPart ? 1 : -1;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Different lengths
|
|
2501
|
+
return aParts.length - bParts.length;
|
|
2502
|
+
};
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Run `setInterval` with a maximum number of repetitions.
|
|
2506
|
+
*
|
|
2507
|
+
* Like `setInterval` but automatically clears after N calls.
|
|
2508
|
+
*
|
|
2509
|
+
* @param {Function} callback - Function to call (receives iteration index)
|
|
2510
|
+
* @param {number} delay - Delay between calls in ms
|
|
2511
|
+
* @param {number} repetitions - Maximum number of times to call
|
|
2512
|
+
* @returns {number} Interval ID (can be passed to clearInterval)
|
|
2513
|
+
* @category Timing
|
|
2514
|
+
* @example
|
|
2515
|
+
* bw.setIntervalX(function(i) {
|
|
2516
|
+
* console.log('Iteration', i);
|
|
2517
|
+
* }, 1000, 5); // Runs 5 times, 1 second apart
|
|
2518
|
+
*/
|
|
2519
|
+
bw.setIntervalX = function(callback, delay, repetitions) {
|
|
2520
|
+
let count = 0;
|
|
2521
|
+
const intervalID = setInterval(function() {
|
|
2522
|
+
callback(count);
|
|
2523
|
+
|
|
2524
|
+
if (++count >= repetitions) {
|
|
2525
|
+
clearInterval(intervalID);
|
|
2526
|
+
}
|
|
2527
|
+
}, delay);
|
|
2528
|
+
|
|
2529
|
+
return intervalID;
|
|
2530
|
+
};
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* Repeat a test function until it returns truthy, or give up after max attempts.
|
|
2534
|
+
*
|
|
2535
|
+
* Useful for polling (waiting for an element to appear, an API to respond, etc.).
|
|
2536
|
+
*
|
|
2537
|
+
* @param {Function} testFn - Test function that returns truthy when done
|
|
2538
|
+
* @param {Function} successFn - Called with test result when test passes
|
|
2539
|
+
* @param {Function} [failFn] - Called on each failed test attempt
|
|
2540
|
+
* @param {number} [delay=250] - Delay between attempts in ms
|
|
2541
|
+
* @param {number} [maxReps=10] - Maximum number of attempts
|
|
2542
|
+
* @param {Function} [lastFn] - Called when done with (success, count)
|
|
2543
|
+
* @returns {string|number} "err" if invalid params, otherwise interval ID
|
|
2544
|
+
* @category Timing
|
|
2545
|
+
* @example
|
|
2546
|
+
* bw.repeatUntil(
|
|
2547
|
+
* function() { return document.getElementById('myDiv'); },
|
|
2548
|
+
* function() { console.log('Element found!'); },
|
|
2549
|
+
* null, 100, 30
|
|
2550
|
+
* );
|
|
2551
|
+
*/
|
|
2552
|
+
bw.repeatUntil = function(testFn, successFn, failFn, delay = 250, maxReps = 10, lastFn) {
|
|
2553
|
+
if (typeof testFn !== "function") return "err";
|
|
2554
|
+
|
|
2555
|
+
let count = 0;
|
|
2556
|
+
|
|
2557
|
+
const intervalID = setInterval(function() {
|
|
2558
|
+
const result = testFn();
|
|
2559
|
+
count++;
|
|
2560
|
+
|
|
2561
|
+
if (result) {
|
|
2562
|
+
clearInterval(intervalID);
|
|
2563
|
+
if (successFn) successFn(result);
|
|
2564
|
+
if (lastFn) lastFn(true, count);
|
|
2565
|
+
} else if (count >= maxReps) {
|
|
2566
|
+
clearInterval(intervalID);
|
|
2567
|
+
if (failFn) failFn();
|
|
2568
|
+
if (lastFn) lastFn(false, count);
|
|
2569
|
+
} else {
|
|
2570
|
+
if (failFn) failFn();
|
|
2571
|
+
}
|
|
2572
|
+
}, delay);
|
|
2573
|
+
|
|
2574
|
+
return intervalID;
|
|
2575
|
+
};
|
|
2576
|
+
|
|
2577
|
+
// ===================================================================================
|
|
2578
|
+
// File I/O Functions - Works in both Node.js and browser
|
|
2579
|
+
// ===================================================================================
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Save data to a file. Works in both Node.js (fs.writeFile) and browser (download link).
|
|
2583
|
+
*
|
|
2584
|
+
* @param {string} fname - Filename to save as
|
|
2585
|
+
* @param {*} data - Data to save (string or buffer)
|
|
2586
|
+
* @category File I/O
|
|
2587
|
+
* @see bw.saveClientJSON
|
|
2588
|
+
*/
|
|
2589
|
+
bw.saveClientFile = function(fname, data) {
|
|
2590
|
+
if (bw.isNodeJS()) {
|
|
2591
|
+
bw._getFs().then(function(fs) {
|
|
2592
|
+
if (!fs) { console.error('bw.saveClientFile: fs module not available'); return; }
|
|
2593
|
+
fs.writeFile(fname, data, function(err) {
|
|
2594
|
+
if (err) {
|
|
2595
|
+
console.error("Error saving file:", err);
|
|
2596
|
+
}
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
} else {
|
|
2600
|
+
// Browser environment
|
|
2601
|
+
const blob = new Blob([data], { type: "application/octet-stream" });
|
|
2602
|
+
const url = window.URL.createObjectURL(blob);
|
|
2603
|
+
const a = bw.createDOM({
|
|
2604
|
+
t: 'a',
|
|
2605
|
+
a: {
|
|
2606
|
+
href: url,
|
|
2607
|
+
download: fname,
|
|
2608
|
+
style: 'display: none'
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
document.body.appendChild(a);
|
|
2612
|
+
a.click();
|
|
2613
|
+
window.URL.revokeObjectURL(url);
|
|
2614
|
+
document.body.removeChild(a);
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
|
|
2618
|
+
/**
|
|
2619
|
+
* Save data as a JSON file with pretty formatting.
|
|
2620
|
+
*
|
|
2621
|
+
* @param {string} fname - Filename to save as
|
|
2622
|
+
* @param {*} data - Data to serialize as JSON
|
|
2623
|
+
* @category File I/O
|
|
2624
|
+
* @see bw.saveClientFile
|
|
2625
|
+
*/
|
|
2626
|
+
bw.saveClientJSON = function(fname, data) {
|
|
2627
|
+
bw.saveClientFile(fname, JSON.stringify(data, null, 2));
|
|
2628
|
+
};
|
|
2629
|
+
|
|
2630
|
+
/**
|
|
2631
|
+
* Load a file by path (Node.js) or URL (browser via XHR).
|
|
2632
|
+
*
|
|
2633
|
+
* @param {string} fname - File path (Node) or URL (browser)
|
|
2634
|
+
* @param {Function} callback - Called with (data, error). data is null on error.
|
|
2635
|
+
* @param {Object} [options] - Options
|
|
2636
|
+
* @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
|
|
2637
|
+
* @returns {string} "BW_OK"
|
|
2638
|
+
* @category File I/O
|
|
2639
|
+
* @see bw.loadClientJSON
|
|
2640
|
+
*/
|
|
2641
|
+
bw.loadClientFile = function(fname, callback, options) {
|
|
2642
|
+
var opts = { parser: 'raw' };
|
|
2643
|
+
if (options && options.parser) { opts.parser = options.parser; }
|
|
2644
|
+
var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
|
|
2645
|
+
|
|
2646
|
+
if (bw.isNodeJS()) {
|
|
2647
|
+
bw._getFs().then(function(fs) {
|
|
2648
|
+
if (!fs) { callback(null, new Error('fs module not available')); return; }
|
|
2649
|
+
fs.readFile(fname, 'utf8', function(err, data) {
|
|
2650
|
+
if (err) { callback(null, err); }
|
|
2651
|
+
else {
|
|
2652
|
+
try { callback(parse(data), null); }
|
|
2653
|
+
catch (e) { callback(null, e); }
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
});
|
|
2657
|
+
} else {
|
|
2658
|
+
var x = new XMLHttpRequest();
|
|
2659
|
+
x.open('GET', fname, true);
|
|
2660
|
+
x.onreadystatechange = function() {
|
|
2661
|
+
if (x.readyState === 4) {
|
|
2662
|
+
if (x.status >= 200 && x.status < 300) {
|
|
2663
|
+
try { callback(parse(x.responseText), null); }
|
|
2664
|
+
catch (e) { callback(null, e); }
|
|
2665
|
+
} else {
|
|
2666
|
+
callback(null, new Error('HTTP ' + x.status + ': ' + fname));
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
x.send(null);
|
|
2671
|
+
}
|
|
2672
|
+
return 'BW_OK';
|
|
2673
|
+
};
|
|
2674
|
+
|
|
2675
|
+
/**
|
|
2676
|
+
* Load a JSON file by path (Node.js) or URL (browser). Convenience wrapper
|
|
2677
|
+
* around `bw.loadClientFile()` with `parser: "JSON"`.
|
|
2678
|
+
*
|
|
2679
|
+
* @param {string} fname - File path (Node) or URL (browser)
|
|
2680
|
+
* @param {Function} callback - Called with (parsedData, error)
|
|
2681
|
+
* @returns {string} "BW_OK"
|
|
2682
|
+
* @category File I/O
|
|
2683
|
+
* @see bw.loadClientFile
|
|
2684
|
+
*/
|
|
2685
|
+
bw.loadClientJSON = function(fname, callback) {
|
|
2686
|
+
return bw.loadClientFile(fname, callback, { parser: 'JSON' });
|
|
2687
|
+
};
|
|
2688
|
+
|
|
2689
|
+
/**
|
|
2690
|
+
* Prompt user to pick a local file via file dialog (browser only).
|
|
2691
|
+
*
|
|
2692
|
+
* Opens a native file picker and reads the selected file.
|
|
2693
|
+
*
|
|
2694
|
+
* @param {Function} callback - Called with (data, filename, error)
|
|
2695
|
+
* @param {Object} [options] - Options
|
|
2696
|
+
* @param {string} [options.accept] - File type filter (e.g. ".json,.txt")
|
|
2697
|
+
* @param {string} [options.parser="raw"] - "raw" for string, "JSON" to auto-parse
|
|
2698
|
+
* @category File I/O
|
|
2699
|
+
* @see bw.loadLocalJSON
|
|
2700
|
+
*/
|
|
2701
|
+
bw.loadLocalFile = function(callback, options) {
|
|
2702
|
+
var opts = { parser: 'raw', accept: '' };
|
|
2703
|
+
if (options) {
|
|
2704
|
+
if (options.parser) { opts.parser = options.parser; }
|
|
2705
|
+
if (options.accept) { opts.accept = options.accept; }
|
|
2706
|
+
}
|
|
2707
|
+
var parse = (opts.parser === 'JSON') ? JSON.parse : function(s) { return s; };
|
|
2708
|
+
|
|
2709
|
+
if (bw.isNodeJS()) {
|
|
2710
|
+
callback(null, '', new Error('bw.loadLocalFile is browser-only. Use bw.loadClientFile() in Node.'));
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
var input = bw.createDOM({
|
|
2715
|
+
t: 'input',
|
|
2716
|
+
a: {
|
|
2717
|
+
type: 'file',
|
|
2718
|
+
accept: opts.accept,
|
|
2719
|
+
style: 'display: none'
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
input.addEventListener('change', function() {
|
|
2723
|
+
var file = input.files[0];
|
|
2724
|
+
if (!file) { callback(null, '', new Error('No file selected')); return; }
|
|
2725
|
+
var reader = new FileReader();
|
|
2726
|
+
reader.onload = function(e) {
|
|
2727
|
+
try { callback(parse(e.target.result), file.name, null); }
|
|
2728
|
+
catch (err) { callback(null, file.name, err); }
|
|
2729
|
+
};
|
|
2730
|
+
reader.onerror = function() { callback(null, file.name, reader.error); };
|
|
2731
|
+
reader.readAsText(file);
|
|
2732
|
+
input.remove();
|
|
2733
|
+
});
|
|
2734
|
+
document.body.appendChild(input);
|
|
2735
|
+
input.click();
|
|
2736
|
+
};
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Prompt user to pick a local JSON file via file dialog (browser only).
|
|
2740
|
+
*
|
|
2741
|
+
* @param {Function} callback - Called with (parsedData, filename, error)
|
|
2742
|
+
* @category File I/O
|
|
2743
|
+
* @see bw.loadLocalFile
|
|
2744
|
+
*/
|
|
2745
|
+
bw.loadLocalJSON = function(callback) {
|
|
2746
|
+
bw.loadLocalFile(callback, { parser: 'JSON', accept: '.json' });
|
|
2747
|
+
};
|
|
2748
|
+
|
|
2749
|
+
/**
|
|
2750
|
+
* Copy text to the system clipboard (browser only).
|
|
2751
|
+
*
|
|
2752
|
+
* Uses the modern Clipboard API when available, falls back to `document.execCommand('copy')`.
|
|
2753
|
+
*
|
|
2754
|
+
* @param {string} text - Text to copy
|
|
2755
|
+
* @returns {Promise} Promise that resolves when copy is complete
|
|
2756
|
+
* @category Browser Utilities
|
|
2757
|
+
*/
|
|
2758
|
+
bw.copyToClipboard = function(text) {
|
|
2759
|
+
// Modern clipboard API
|
|
2760
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2761
|
+
return navigator.clipboard.writeText(text);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// Fallback for older browsers
|
|
2765
|
+
return new Promise((resolve, reject) => {
|
|
2766
|
+
const textarea = bw.createDOM({
|
|
2767
|
+
t: 'textarea',
|
|
2768
|
+
a: {
|
|
2769
|
+
value: text,
|
|
2770
|
+
style: {
|
|
2771
|
+
position: 'fixed',
|
|
2772
|
+
top: '-999px',
|
|
2773
|
+
left: '-999px',
|
|
2774
|
+
width: '2em',
|
|
2775
|
+
height: '2em',
|
|
2776
|
+
padding: 0,
|
|
2777
|
+
border: 'none',
|
|
2778
|
+
outline: 'none',
|
|
2779
|
+
boxShadow: 'none',
|
|
2780
|
+
background: 'transparent'
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
document.body.appendChild(textarea);
|
|
2786
|
+
textarea.focus();
|
|
2787
|
+
textarea.select();
|
|
2788
|
+
|
|
2789
|
+
try {
|
|
2790
|
+
const successful = document.execCommand('copy');
|
|
2791
|
+
document.body.removeChild(textarea);
|
|
2792
|
+
|
|
2793
|
+
if (successful) {
|
|
2794
|
+
resolve();
|
|
2795
|
+
} else {
|
|
2796
|
+
reject(new Error('Copy command failed'));
|
|
2797
|
+
}
|
|
2798
|
+
} catch (err) {
|
|
2799
|
+
document.body.removeChild(textarea);
|
|
2800
|
+
reject(err);
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
};
|
|
2804
|
+
|
|
2805
|
+
/**
|
|
2806
|
+
* Create a sortable TACO table from an array of row objects.
|
|
2807
|
+
*
|
|
2808
|
+
* Auto-detects columns from data keys if not specified. Supports click-to-sort
|
|
2809
|
+
* headers with ascending/descending indicators. Returns a TACO object —
|
|
2810
|
+
* render with `bw.DOM()` or `bw.html()`.
|
|
2811
|
+
*
|
|
2812
|
+
* @param {Object} config - Table configuration
|
|
2813
|
+
* @param {Array<Object>} config.data - Array of row objects to display
|
|
2814
|
+
* @param {Array<Object>} [config.columns] - Column definitions with key, label, render
|
|
2815
|
+
* @param {string} [config.className='table'] - CSS class for table element
|
|
2816
|
+
* @param {boolean} [config.sortable=true] - Enable click-to-sort headers
|
|
2817
|
+
* @param {Function} [config.onSort] - Sort callback (column, direction)
|
|
2818
|
+
* @returns {Object} TACO object for table
|
|
2819
|
+
* @category Component Builders
|
|
2820
|
+
* @see bw.makeDataTable
|
|
2821
|
+
* @example
|
|
2822
|
+
* bw.makeTable({
|
|
2823
|
+
* data: [
|
|
2824
|
+
* { name: 'Alice', age: 30 },
|
|
2825
|
+
* { name: 'Bob', age: 25 }
|
|
2826
|
+
* ],
|
|
2827
|
+
* columns: [
|
|
2828
|
+
* { key: 'name', label: 'Name' },
|
|
2829
|
+
* { key: 'age', label: 'Age' }
|
|
2830
|
+
* ]
|
|
2831
|
+
* });
|
|
2832
|
+
*/
|
|
2833
|
+
bw.makeTable = function(config) {
|
|
2834
|
+
const {
|
|
2835
|
+
data = [],
|
|
2836
|
+
columns,
|
|
2837
|
+
className = "table",
|
|
2838
|
+
sortable = true,
|
|
2839
|
+
onSort,
|
|
2840
|
+
sortColumn,
|
|
2841
|
+
sortDirection = 'asc'
|
|
2842
|
+
} = config;
|
|
2843
|
+
|
|
2844
|
+
// Auto-detect columns if not provided
|
|
2845
|
+
const cols = columns || (data.length > 0
|
|
2846
|
+
? Object.keys(data[0]).map(key => ({ key, label: key }))
|
|
2847
|
+
: []);
|
|
2848
|
+
|
|
2849
|
+
// Current sort state
|
|
2850
|
+
let currentSortColumn = sortColumn || null;
|
|
2851
|
+
let currentSortDirection = sortDirection;
|
|
2852
|
+
|
|
2853
|
+
// Sort data if column specified
|
|
2854
|
+
let sortedData = [...data];
|
|
2855
|
+
if (currentSortColumn) {
|
|
2856
|
+
sortedData.sort((a, b) => {
|
|
2857
|
+
const aVal = a[currentSortColumn];
|
|
2858
|
+
const bVal = b[currentSortColumn];
|
|
2859
|
+
|
|
2860
|
+
// Handle different types
|
|
2861
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
2862
|
+
return currentSortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// String comparison
|
|
2866
|
+
const aStr = String(aVal || '').toLowerCase();
|
|
2867
|
+
const bStr = String(bVal || '').toLowerCase();
|
|
2868
|
+
|
|
2869
|
+
if (currentSortDirection === 'asc') {
|
|
2870
|
+
return aStr.localeCompare(bStr);
|
|
2871
|
+
} else {
|
|
2872
|
+
return bStr.localeCompare(aStr);
|
|
2873
|
+
}
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// Create sort handler
|
|
2878
|
+
const handleSort = (column) => {
|
|
2879
|
+
if (!sortable) return;
|
|
2880
|
+
|
|
2881
|
+
if (currentSortColumn === column) {
|
|
2882
|
+
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
|
2883
|
+
} else {
|
|
2884
|
+
currentSortColumn = column;
|
|
2885
|
+
currentSortDirection = 'asc';
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
if (onSort) {
|
|
2889
|
+
onSort(column, currentSortDirection);
|
|
2890
|
+
}
|
|
2891
|
+
};
|
|
2892
|
+
|
|
2893
|
+
// Build table header
|
|
2894
|
+
const thead = {
|
|
2895
|
+
t: 'thead',
|
|
2896
|
+
c: {
|
|
2897
|
+
t: 'tr',
|
|
2898
|
+
c: cols.map(col => ({
|
|
2899
|
+
t: 'th',
|
|
2900
|
+
a: sortable ? {
|
|
2901
|
+
style: { cursor: 'pointer', userSelect: 'none' },
|
|
2902
|
+
onclick: () => handleSort(col.key)
|
|
2903
|
+
} : {},
|
|
2904
|
+
c: [
|
|
2905
|
+
col.label,
|
|
2906
|
+
sortable && currentSortColumn === col.key && {
|
|
2907
|
+
t: 'span',
|
|
2908
|
+
a: { style: { marginLeft: '5px' } },
|
|
2909
|
+
c: currentSortDirection === 'asc' ? '▲' : '▼'
|
|
2910
|
+
}
|
|
2911
|
+
].filter(Boolean)
|
|
2912
|
+
}))
|
|
2913
|
+
}
|
|
2914
|
+
};
|
|
2915
|
+
|
|
2916
|
+
// Build table body
|
|
2917
|
+
const tbody = {
|
|
2918
|
+
t: 'tbody',
|
|
2919
|
+
c: sortedData.map(row => ({
|
|
2920
|
+
t: 'tr',
|
|
2921
|
+
c: cols.map(col => ({
|
|
2922
|
+
t: 'td',
|
|
2923
|
+
c: col.render ? col.render(row[col.key], row) : String(row[col.key] || '')
|
|
2924
|
+
}))
|
|
2925
|
+
}))
|
|
2926
|
+
};
|
|
2927
|
+
|
|
2928
|
+
return {
|
|
2929
|
+
t: 'table',
|
|
2930
|
+
a: { class: className },
|
|
2931
|
+
c: [thead, tbody]
|
|
2932
|
+
};
|
|
2933
|
+
};
|
|
2934
|
+
|
|
2935
|
+
/**
|
|
2936
|
+
* Create a responsive data table with title and optional wrapper
|
|
2937
|
+
*
|
|
2938
|
+
* Wraps bw.makeTable() output in a responsive container div.
|
|
2939
|
+
* Adds an optional title heading above the table.
|
|
2940
|
+
*
|
|
2941
|
+
* @param {Object} config - Table configuration
|
|
2942
|
+
* @param {string} [config.title] - Table title heading
|
|
2943
|
+
* @param {Array<Object>} config.data - Array of row objects
|
|
2944
|
+
* @param {Array<Object>} [config.columns] - Column definitions
|
|
2945
|
+
* @param {string} [config.className="table table-striped table-hover"] - Table CSS class
|
|
2946
|
+
* @param {boolean} [config.responsive=true] - Wrap table in responsive overflow div
|
|
2947
|
+
* @returns {Object} TACO object for table with wrapper
|
|
2948
|
+
* @example
|
|
2949
|
+
* const table = bw.makeDataTable({
|
|
2950
|
+
* title: "Users",
|
|
2951
|
+
* data: [{ name: "Alice", role: "Admin" }],
|
|
2952
|
+
* responsive: true
|
|
2953
|
+
* });
|
|
2954
|
+
*/
|
|
2955
|
+
bw.makeDataTable = function(config) {
|
|
2956
|
+
const {
|
|
2957
|
+
title,
|
|
2958
|
+
data,
|
|
2959
|
+
columns,
|
|
2960
|
+
className = "table table-striped table-hover",
|
|
2961
|
+
responsive = true,
|
|
2962
|
+
...tableConfig
|
|
2963
|
+
} = config;
|
|
2964
|
+
|
|
2965
|
+
const table = bw.makeTable({
|
|
2966
|
+
data,
|
|
2967
|
+
columns,
|
|
2968
|
+
className,
|
|
2969
|
+
...tableConfig
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
const content = [];
|
|
2973
|
+
|
|
2974
|
+
if (title) {
|
|
2975
|
+
content.push({
|
|
2976
|
+
t: 'h5',
|
|
2977
|
+
a: { class: 'mb-3' },
|
|
2978
|
+
c: title
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
if (responsive) {
|
|
2983
|
+
content.push({
|
|
2984
|
+
t: 'div',
|
|
2985
|
+
a: { class: 'table-responsive' },
|
|
2986
|
+
c: table
|
|
2987
|
+
});
|
|
2988
|
+
} else {
|
|
2989
|
+
content.push(table);
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
return {
|
|
2993
|
+
t: 'div',
|
|
2994
|
+
a: { class: 'table-container' },
|
|
2995
|
+
c: content
|
|
2996
|
+
};
|
|
2997
|
+
};
|
|
2998
|
+
|
|
2999
|
+
/**
|
|
3000
|
+
* Component registry for tracking rendered components
|
|
3001
|
+
* @private
|
|
3002
|
+
*/
|
|
3003
|
+
bw._componentRegistry = new Map();
|
|
3004
|
+
|
|
3005
|
+
/**
|
|
3006
|
+
* Render a TACO object into the DOM at a specific position, returning a component handle.
|
|
3007
|
+
*
|
|
3008
|
+
* The handle provides full lifecycle control: state management, re-rendering,
|
|
3009
|
+
* class manipulation, show/hide, event binding, and destroy. Components are
|
|
3010
|
+
* tracked in a registry for later retrieval via `bw.getComponent()`.
|
|
3011
|
+
*
|
|
3012
|
+
* @param {Element|string} element - Target element or CSS selector
|
|
3013
|
+
* @param {string} position - Position: 'replace', 'prepend', 'append', 'before', 'after'
|
|
3014
|
+
* @param {Object} taco - TACO object to render
|
|
3015
|
+
* @returns {Object} Component handle with element, setState, update, destroy, etc.
|
|
3016
|
+
* @category DOM Generation
|
|
3017
|
+
* @see bw.getComponent
|
|
3018
|
+
* @see bw.DOM
|
|
3019
|
+
* @example
|
|
3020
|
+
* var handle = bw.render('#app', 'append', {
|
|
3021
|
+
* t: 'button', a: { class: 'bw-btn' }, c: 'Click Me',
|
|
3022
|
+
* o: { state: { clicks: 0 } }
|
|
3023
|
+
* });
|
|
3024
|
+
* handle.setState({ clicks: 1 });
|
|
3025
|
+
* handle.destroy();
|
|
3026
|
+
*/
|
|
3027
|
+
bw.render = function(element, position, taco) {
|
|
3028
|
+
// Get target element
|
|
3029
|
+
const targetEl = typeof element === 'string'
|
|
3030
|
+
? document.querySelector(element)
|
|
3031
|
+
: element;
|
|
3032
|
+
|
|
3033
|
+
if (!targetEl) {
|
|
3034
|
+
return {
|
|
3035
|
+
object_type: 'error',
|
|
3036
|
+
component_id: null,
|
|
3037
|
+
object_handle_in_dom: null,
|
|
3038
|
+
status_code: 'error=target_element_not_found'
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// Generate unique ID if not provided
|
|
3043
|
+
const componentId = taco.o?.id || bw.uuid();
|
|
3044
|
+
|
|
3045
|
+
// Create DOM element
|
|
3046
|
+
let domElement;
|
|
3047
|
+
try {
|
|
3048
|
+
domElement = bw.createDOM(taco);
|
|
3049
|
+
} catch(e) {
|
|
3050
|
+
return {
|
|
3051
|
+
object_type: 'error',
|
|
3052
|
+
component_id: componentId,
|
|
3053
|
+
object_handle_in_dom: null,
|
|
3054
|
+
status_code: `error=render_failed:${e.message}`
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
// Add component ID to element
|
|
3059
|
+
domElement.setAttribute('data-bw-id', componentId);
|
|
3060
|
+
|
|
3061
|
+
// Insert into DOM based on position
|
|
3062
|
+
try {
|
|
3063
|
+
switch(position) {
|
|
3064
|
+
case 'replace':
|
|
3065
|
+
targetEl.parentNode.replaceChild(domElement, targetEl);
|
|
3066
|
+
break;
|
|
3067
|
+
case 'prepend':
|
|
3068
|
+
targetEl.insertBefore(domElement, targetEl.firstChild);
|
|
3069
|
+
break;
|
|
3070
|
+
case 'append':
|
|
3071
|
+
targetEl.appendChild(domElement);
|
|
3072
|
+
break;
|
|
3073
|
+
case 'before':
|
|
3074
|
+
targetEl.parentNode.insertBefore(domElement, targetEl);
|
|
3075
|
+
break;
|
|
3076
|
+
case 'after':
|
|
3077
|
+
targetEl.parentNode.insertBefore(domElement, targetEl.nextSibling);
|
|
3078
|
+
break;
|
|
3079
|
+
default:
|
|
3080
|
+
throw new Error(`Invalid position: ${position}`);
|
|
3081
|
+
}
|
|
3082
|
+
} catch(e) {
|
|
3083
|
+
return {
|
|
3084
|
+
object_type: 'error',
|
|
3085
|
+
component_id: componentId,
|
|
3086
|
+
object_handle_in_dom: null,
|
|
3087
|
+
status_code: `error=insertion_failed:${e.message}`
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
// Create component handle
|
|
3092
|
+
const handle = {
|
|
3093
|
+
object_type: taco.t || 'element',
|
|
3094
|
+
component_id: componentId,
|
|
3095
|
+
object_handle_in_dom: domElement,
|
|
3096
|
+
status_code: 'success',
|
|
3097
|
+
|
|
3098
|
+
// Reference to original TACO
|
|
3099
|
+
_taco: { ...taco },
|
|
3100
|
+
_state: { ...(taco.o?.state || {}) },
|
|
3101
|
+
_mounted: true,
|
|
3102
|
+
|
|
3103
|
+
// Get DOM element
|
|
3104
|
+
get element() {
|
|
3105
|
+
return this.object_handle_in_dom;
|
|
3106
|
+
},
|
|
3107
|
+
|
|
3108
|
+
// Get/set state
|
|
3109
|
+
getState() {
|
|
3110
|
+
return { ...this._state };
|
|
3111
|
+
},
|
|
3112
|
+
|
|
3113
|
+
setState(updates) {
|
|
3114
|
+
this._state = { ...this._state, ...updates };
|
|
3115
|
+
if (this._taco.o?.onStateChange) {
|
|
3116
|
+
this._taco.o.onStateChange(this._state, updates);
|
|
3117
|
+
}
|
|
3118
|
+
return this;
|
|
3119
|
+
},
|
|
3120
|
+
|
|
3121
|
+
// Update component (re-render)
|
|
3122
|
+
update() {
|
|
3123
|
+
if (!this._mounted || !this.element) return this;
|
|
3124
|
+
|
|
3125
|
+
const parent = this.element.parentNode;
|
|
3126
|
+
|
|
3127
|
+
// Update TACO with current state
|
|
3128
|
+
if (this._taco.o) {
|
|
3129
|
+
this._taco.o.state = this._state;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// Re-render
|
|
3133
|
+
const newElement = bw.createDOM(this._taco);
|
|
3134
|
+
newElement.setAttribute('data-bw-id', componentId);
|
|
3135
|
+
|
|
3136
|
+
// Replace in DOM
|
|
3137
|
+
parent.replaceChild(newElement, this.element);
|
|
3138
|
+
this.object_handle_in_dom = newElement;
|
|
3139
|
+
|
|
3140
|
+
// Call update lifecycle
|
|
3141
|
+
if (this._taco.o?.onUpdate) {
|
|
3142
|
+
this._taco.o.onUpdate(newElement, this._state);
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
return this;
|
|
3146
|
+
},
|
|
3147
|
+
|
|
3148
|
+
// Get/set properties
|
|
3149
|
+
getProp(key) {
|
|
3150
|
+
return this._taco.a?.[key];
|
|
3151
|
+
},
|
|
3152
|
+
|
|
3153
|
+
setProp(key, value) {
|
|
3154
|
+
if (!this._taco.a) this._taco.a = {};
|
|
3155
|
+
this._taco.a[key] = value;
|
|
3156
|
+
|
|
3157
|
+
// Update DOM attribute
|
|
3158
|
+
if (this.element) {
|
|
3159
|
+
if (value === null || value === undefined) {
|
|
3160
|
+
this.element.removeAttribute(key);
|
|
3161
|
+
} else if (value === true) {
|
|
3162
|
+
this.element.setAttribute(key, '');
|
|
3163
|
+
} else {
|
|
3164
|
+
this.element.setAttribute(key, String(value));
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
return this;
|
|
3169
|
+
},
|
|
3170
|
+
|
|
3171
|
+
// Get/set content
|
|
3172
|
+
getContent() {
|
|
3173
|
+
return this._taco.c;
|
|
3174
|
+
},
|
|
3175
|
+
|
|
3176
|
+
setContent(content) {
|
|
3177
|
+
this._taco.c = content;
|
|
3178
|
+
if (this.element) {
|
|
3179
|
+
if (typeof content === 'string') {
|
|
3180
|
+
this.element.textContent = content;
|
|
3181
|
+
} else {
|
|
3182
|
+
// Re-render for complex content
|
|
3183
|
+
this.update();
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return this;
|
|
3187
|
+
},
|
|
3188
|
+
|
|
3189
|
+
// Add/remove CSS classes
|
|
3190
|
+
addClass(className) {
|
|
3191
|
+
if (this.element) {
|
|
3192
|
+
this.element.classList.add(className);
|
|
3193
|
+
}
|
|
3194
|
+
return this;
|
|
3195
|
+
},
|
|
3196
|
+
|
|
3197
|
+
removeClass(className) {
|
|
3198
|
+
if (this.element) {
|
|
3199
|
+
this.element.classList.remove(className);
|
|
3200
|
+
}
|
|
3201
|
+
return this;
|
|
3202
|
+
},
|
|
3203
|
+
|
|
3204
|
+
toggleClass(className) {
|
|
3205
|
+
if (this.element) {
|
|
3206
|
+
this.element.classList.toggle(className);
|
|
3207
|
+
}
|
|
3208
|
+
return this;
|
|
3209
|
+
},
|
|
3210
|
+
|
|
3211
|
+
hasClass(className) {
|
|
3212
|
+
return this.element ? this.element.classList.contains(className) : false;
|
|
3213
|
+
},
|
|
3214
|
+
|
|
3215
|
+
// Show/hide
|
|
3216
|
+
show() {
|
|
3217
|
+
if (this.element) {
|
|
3218
|
+
this.element.style.display = '';
|
|
3219
|
+
}
|
|
3220
|
+
return this;
|
|
3221
|
+
},
|
|
3222
|
+
|
|
3223
|
+
hide() {
|
|
3224
|
+
if (this.element) {
|
|
3225
|
+
this.element.style.display = 'none';
|
|
3226
|
+
}
|
|
3227
|
+
return this;
|
|
3228
|
+
},
|
|
3229
|
+
|
|
3230
|
+
// Event handling
|
|
3231
|
+
on(event, handler) {
|
|
3232
|
+
if (this.element) {
|
|
3233
|
+
this.element.addEventListener(event, handler);
|
|
3234
|
+
}
|
|
3235
|
+
return this;
|
|
3236
|
+
},
|
|
3237
|
+
|
|
3238
|
+
off(event, handler) {
|
|
3239
|
+
if (this.element) {
|
|
3240
|
+
this.element.removeEventListener(event, handler);
|
|
3241
|
+
}
|
|
3242
|
+
return this;
|
|
3243
|
+
},
|
|
3244
|
+
|
|
3245
|
+
// Destroy component
|
|
3246
|
+
destroy() {
|
|
3247
|
+
if (!this._mounted) return this;
|
|
3248
|
+
|
|
3249
|
+
// Call unmount lifecycle
|
|
3250
|
+
if (this._taco.o?.unmount) {
|
|
3251
|
+
this._taco.o.unmount(this.element);
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// Remove from DOM
|
|
3255
|
+
if (this.element && this.element.parentNode) {
|
|
3256
|
+
this.element.parentNode.removeChild(this.element);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// Remove from registry
|
|
3260
|
+
bw._componentRegistry.delete(componentId);
|
|
3261
|
+
|
|
3262
|
+
// Clean up
|
|
3263
|
+
this._mounted = false;
|
|
3264
|
+
this.object_handle_in_dom = null;
|
|
3265
|
+
this.status_code = 'destroyed';
|
|
3266
|
+
|
|
3267
|
+
return this;
|
|
3268
|
+
}
|
|
3269
|
+
};
|
|
3270
|
+
|
|
3271
|
+
// Store in registry
|
|
3272
|
+
bw._componentRegistry.set(componentId, handle);
|
|
3273
|
+
|
|
3274
|
+
// Call mounted lifecycle
|
|
3275
|
+
if (taco.o?.mounted) {
|
|
3276
|
+
taco.o.mounted(domElement, handle);
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
return handle;
|
|
3280
|
+
};
|
|
3281
|
+
|
|
3282
|
+
/**
|
|
3283
|
+
* Get a component handle by its ID from the component registry.
|
|
3284
|
+
*
|
|
3285
|
+
* @param {string} id - Component ID (from bw.render)
|
|
3286
|
+
* @returns {Object|null} Component handle or null if not found
|
|
3287
|
+
* @category DOM Generation
|
|
3288
|
+
* @see bw.render
|
|
3289
|
+
*/
|
|
3290
|
+
bw.getComponent = function(id) {
|
|
3291
|
+
return bw._componentRegistry.get(id) || null;
|
|
3292
|
+
};
|
|
3293
|
+
|
|
3294
|
+
/**
|
|
3295
|
+
* Get all registered component handles as a Map.
|
|
3296
|
+
*
|
|
3297
|
+
* @returns {Map} Map of componentId → component handle
|
|
3298
|
+
* @category DOM Generation
|
|
3299
|
+
* @see bw.getComponent
|
|
3300
|
+
*/
|
|
3301
|
+
bw.getAllComponents = function() {
|
|
3302
|
+
return new Map(bw._componentRegistry);
|
|
3303
|
+
};
|
|
3304
|
+
|
|
3305
|
+
// =========================================================================
|
|
3306
|
+
// Import and register all components
|
|
3307
|
+
// =========================================================================
|
|
3308
|
+
import * as components from './bitwrench-components-v2.js';
|
|
3309
|
+
|
|
3310
|
+
// Register all make functions
|
|
3311
|
+
Object.entries(components).forEach(([name, fn]) => {
|
|
3312
|
+
if (name.startsWith('make')) {
|
|
3313
|
+
bw[name] = fn;
|
|
3314
|
+
}
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3317
|
+
// Register component handles
|
|
3318
|
+
bw._componentHandles = components.componentHandles || {};
|
|
3319
|
+
|
|
3320
|
+
// Create functions that return handles
|
|
3321
|
+
Object.entries(components).forEach(([name, fn]) => {
|
|
3322
|
+
if (name.startsWith('make')) {
|
|
3323
|
+
const componentType = name.substring(4).toLowerCase(); // Remove 'make' prefix
|
|
3324
|
+
const createName = 'create' + name.substring(4); // createCard, createTable, etc.
|
|
3325
|
+
|
|
3326
|
+
bw[createName] = function(props) {
|
|
3327
|
+
const taco = fn(props);
|
|
3328
|
+
const handle = bw.renderComponent(taco);
|
|
3329
|
+
|
|
3330
|
+
// Use specialized handle class if available
|
|
3331
|
+
const HandleClass = bw._componentHandles[componentType];
|
|
3332
|
+
if (HandleClass) {
|
|
3333
|
+
const specializedHandle = new HandleClass(handle.element, taco);
|
|
3334
|
+
// Copy base handle properties
|
|
3335
|
+
Object.setPrototypeOf(specializedHandle, handle);
|
|
3336
|
+
return specializedHandle;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
return handle;
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
// Manual registration for functions defined in this file
|
|
3345
|
+
// createTable
|
|
3346
|
+
bw.createTable = function(data, options = {}) {
|
|
3347
|
+
const taco = bw.makeTable({ data, ...options });
|
|
3348
|
+
const handle = bw.renderComponent(taco);
|
|
3349
|
+
|
|
3350
|
+
// Use specialized TableHandle
|
|
3351
|
+
const TableHandle = bw._componentHandles.table;
|
|
3352
|
+
if (TableHandle) {
|
|
3353
|
+
const specializedHandle = new TableHandle(handle.element, taco);
|
|
3354
|
+
Object.setPrototypeOf(specializedHandle, handle);
|
|
3355
|
+
return specializedHandle;
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
return handle;
|
|
3359
|
+
};
|
|
3360
|
+
|
|
3361
|
+
// Export for different environments
|
|
3362
|
+
export default bw;
|
|
3363
|
+
|
|
3364
|
+
// Also attach to global in browsers
|
|
3365
|
+
if (bw._isBrowser && typeof window !== 'undefined') {
|
|
3366
|
+
window.bw = bw;
|
|
3367
|
+
}
|