juxscript 1.0.4 → 1.0.6

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.
@@ -13,7 +13,14 @@ export interface InputOptions {
13
13
  disabled?: boolean;
14
14
  name?: string;
15
15
  rows?: number;
16
+ min?: number;
17
+ max?: number;
18
+ step?: number;
19
+ minLength?: number;
20
+ maxLength?: number;
21
+ pattern?: string;
16
22
  onChange?: (value: string) => void;
23
+ onValidate?: (value: string) => boolean | string;
17
24
  style?: string;
18
25
  class?: string;
19
26
  }
@@ -30,23 +37,40 @@ type InputState = {
30
37
  disabled: boolean;
31
38
  name: string;
32
39
  rows: number;
40
+ min?: number;
41
+ max?: number;
42
+ step?: number;
43
+ minLength?: number;
44
+ maxLength?: number;
45
+ pattern?: string;
33
46
  style: string;
34
47
  class: string;
48
+ errorMessage?: string;
35
49
  };
36
50
 
37
51
  /**
38
- * Input component - text input and textarea
52
+ * Input component - text input and textarea with validation
39
53
  *
40
54
  * Usage:
41
55
  * jux.input('username')
42
56
  * .label('Username')
43
57
  * .placeholder('Enter username')
44
58
  * .required(true)
59
+ * .minLength(3)
60
+ * .maxLength(20)
61
+ * .render('#form');
62
+ *
63
+ * jux.input('age')
64
+ * .type('number')
65
+ * .min(0)
66
+ * .max(120)
67
+ * .step(1)
45
68
  * .render('#form');
46
69
  *
47
70
  * jux.input('bio')
48
71
  * .type('textarea')
49
72
  * .rows(5)
73
+ * .maxLength(500)
50
74
  * .render('#form');
51
75
  */
52
76
  export class Input {
@@ -55,12 +79,14 @@ export class Input {
55
79
  _id: string;
56
80
  id: string;
57
81
  private _onChange?: (value: string) => void;
82
+ private _onValidate?: (value: string) => boolean | string;
58
83
  private _boundState?: State<string>;
59
84
 
60
85
  constructor(id: string, options: InputOptions = {}) {
61
86
  this._id = id;
62
87
  this.id = id;
63
88
  this._onChange = options.onChange;
89
+ this._onValidate = options.onValidate;
64
90
 
65
91
  this.state = {
66
92
  type: options.type ?? 'text',
@@ -71,6 +97,12 @@ export class Input {
71
97
  disabled: options.disabled ?? false,
72
98
  name: options.name ?? id,
73
99
  rows: options.rows ?? 3,
100
+ min: options.min,
101
+ max: options.max,
102
+ step: options.step,
103
+ minLength: options.minLength,
104
+ maxLength: options.maxLength,
105
+ pattern: options.pattern,
74
106
  style: options.style ?? '',
75
107
  class: options.class ?? ''
76
108
  };
@@ -85,8 +117,8 @@ export class Input {
85
117
  return this;
86
118
  }
87
119
 
88
- value(value: string): this {
89
- this.state.value = value;
120
+ value(value: string | number): this {
121
+ this.state.value = String(value);
90
122
  this._updateElement();
91
123
  return this;
92
124
  }
@@ -122,6 +154,60 @@ export class Input {
122
154
  return this;
123
155
  }
124
156
 
157
+ /**
158
+ * Minimum value for number inputs
159
+ */
160
+ min(value: number): this {
161
+ this.state.min = value;
162
+ this._updateElement();
163
+ return this;
164
+ }
165
+
166
+ /**
167
+ * Maximum value for number inputs
168
+ */
169
+ max(value: number): this {
170
+ this.state.max = value;
171
+ this._updateElement();
172
+ return this;
173
+ }
174
+
175
+ /**
176
+ * Step value for number inputs
177
+ */
178
+ step(value: number): this {
179
+ this.state.step = value;
180
+ this._updateElement();
181
+ return this;
182
+ }
183
+
184
+ /**
185
+ * Minimum length for text inputs
186
+ */
187
+ minLength(value: number): this {
188
+ this.state.minLength = value;
189
+ this._updateElement();
190
+ return this;
191
+ }
192
+
193
+ /**
194
+ * Maximum length for text inputs
195
+ */
196
+ maxLength(value: number): this {
197
+ this.state.maxLength = value;
198
+ this._updateElement();
199
+ return this;
200
+ }
201
+
202
+ /**
203
+ * Pattern validation for text inputs (regex)
204
+ */
205
+ pattern(value: string): this {
206
+ this.state.pattern = value;
207
+ this._updateElement();
208
+ return this;
209
+ }
210
+
125
211
  style(value: string): this {
126
212
  this.state.style = value;
127
213
  return this;
@@ -137,6 +223,15 @@ export class Input {
137
223
  return this;
138
224
  }
139
225
 
226
+ /**
227
+ * Custom validation function
228
+ * Should return true if valid, or an error message string if invalid
229
+ */
230
+ onValidate(handler: (value: string) => boolean | string): this {
231
+ this._onValidate = handler;
232
+ return this;
233
+ }
234
+
140
235
  /**
141
236
  * Two-way binding to state
142
237
  */
@@ -155,6 +250,112 @@ export class Input {
155
250
  return this;
156
251
  }
157
252
 
253
+ /* -------------------------
254
+ * Validation
255
+ * ------------------------- */
256
+
257
+ private _validate(value: string): boolean | string {
258
+ const { required, type, min, max, minLength, maxLength, pattern } = this.state;
259
+
260
+ // Required check
261
+ if (required && !value.trim()) {
262
+ return 'This field is required';
263
+ }
264
+
265
+ // Number validation
266
+ if (type === 'number' && value) {
267
+ const numValue = Number(value);
268
+
269
+ if (isNaN(numValue)) {
270
+ return 'Must be a valid number';
271
+ }
272
+
273
+ if (min !== undefined && numValue < min) {
274
+ return `Must be at least ${min}`;
275
+ }
276
+
277
+ if (max !== undefined && numValue > max) {
278
+ return `Must be at most ${max}`;
279
+ }
280
+ }
281
+
282
+ // Text length validation
283
+ if ((type === 'text' || type === 'textarea') && value) {
284
+ if (minLength !== undefined && value.length < minLength) {
285
+ return `Must be at least ${minLength} characters`;
286
+ }
287
+
288
+ if (maxLength !== undefined && value.length > maxLength) {
289
+ return `Must be at most ${maxLength} characters`;
290
+ }
291
+ }
292
+
293
+ // Pattern validation
294
+ if (pattern && value) {
295
+ const regex = new RegExp(pattern);
296
+ if (!regex.test(value)) {
297
+ return 'Invalid format';
298
+ }
299
+ }
300
+
301
+ // Custom validation
302
+ if (this._onValidate) {
303
+ const result = this._onValidate(value);
304
+ if (result !== true) {
305
+ return result || 'Invalid value';
306
+ }
307
+ }
308
+
309
+ return true;
310
+ }
311
+
312
+ private _showError(message: string): void {
313
+ const errorEl = document.getElementById(`${this._id}-error`);
314
+ const inputEl = document.getElementById(`${this._id}-input`);
315
+
316
+ if (errorEl) {
317
+ errorEl.textContent = message;
318
+ errorEl.style.display = 'block';
319
+ }
320
+
321
+ if (inputEl) {
322
+ inputEl.classList.add('jux-input-invalid');
323
+ }
324
+
325
+ this.state.errorMessage = message;
326
+ }
327
+
328
+ private _clearError(): void {
329
+ const errorEl = document.getElementById(`${this._id}-error`);
330
+ const inputEl = document.getElementById(`${this._id}-input`);
331
+
332
+ if (errorEl) {
333
+ errorEl.textContent = '';
334
+ errorEl.style.display = 'none';
335
+ }
336
+
337
+ if (inputEl) {
338
+ inputEl.classList.remove('jux-input-invalid');
339
+ }
340
+
341
+ this.state.errorMessage = undefined;
342
+ }
343
+
344
+ /**
345
+ * Manually validate the current value
346
+ */
347
+ validate(): boolean {
348
+ const result = this._validate(this.state.value);
349
+
350
+ if (result === true) {
351
+ this._clearError();
352
+ return true;
353
+ } else {
354
+ this._showError(result as string);
355
+ return false;
356
+ }
357
+ }
358
+
158
359
  /* -------------------------
159
360
  * Helpers
160
361
  * ------------------------- */
@@ -164,6 +365,20 @@ export class Input {
164
365
  if (input) {
165
366
  input.value = this.state.value;
166
367
  input.disabled = this.state.disabled;
368
+
369
+ if (input instanceof HTMLInputElement) {
370
+ if (this.state.min !== undefined) input.min = String(this.state.min);
371
+ if (this.state.max !== undefined) input.max = String(this.state.max);
372
+ if (this.state.step !== undefined) input.step = String(this.state.step);
373
+ if (this.state.minLength !== undefined) input.minLength = this.state.minLength;
374
+ if (this.state.maxLength !== undefined) input.maxLength = this.state.maxLength;
375
+ if (this.state.pattern) input.pattern = this.state.pattern;
376
+ }
377
+
378
+ if (input instanceof HTMLTextAreaElement) {
379
+ if (this.state.minLength !== undefined) input.minLength = this.state.minLength;
380
+ if (this.state.maxLength !== undefined) input.maxLength = this.state.maxLength;
381
+ }
167
382
  }
168
383
  }
169
384
 
@@ -171,6 +386,15 @@ export class Input {
171
386
  return this.state.value;
172
387
  }
173
388
 
389
+ getNumericValue(): number | null {
390
+ const num = Number(this.state.value);
391
+ return isNaN(num) ? null : num;
392
+ }
393
+
394
+ isValid(): boolean {
395
+ return this._validate(this.state.value) === true;
396
+ }
397
+
174
398
  /* -------------------------
175
399
  * Render
176
400
  * ------------------------- */
@@ -189,7 +413,7 @@ export class Input {
189
413
  }
190
414
 
191
415
  this.container = container;
192
- const { type, value, placeholder, label, required, disabled, name, rows, style, class: className } = this.state;
416
+ const { type, value, placeholder, label, required, disabled, name, rows, min, max, step, minLength, maxLength, pattern, style, class: className } = this.state;
193
417
 
194
418
  const wrapper = document.createElement('div');
195
419
  wrapper.className = 'jux-input';
@@ -224,9 +448,25 @@ export class Input {
224
448
  if (type === 'textarea') {
225
449
  inputEl = document.createElement('textarea');
226
450
  inputEl.rows = rows;
451
+ if (minLength !== undefined) inputEl.minLength = minLength;
452
+ if (maxLength !== undefined) inputEl.maxLength = maxLength;
227
453
  } else {
228
454
  inputEl = document.createElement('input');
229
455
  inputEl.type = type;
456
+
457
+ // Number-specific attributes
458
+ if (type === 'number') {
459
+ if (min !== undefined) inputEl.min = String(min);
460
+ if (max !== undefined) inputEl.max = String(max);
461
+ if (step !== undefined) inputEl.step = String(step);
462
+ }
463
+
464
+ // Text-specific attributes
465
+ if (type === 'text' || type === 'email' || type === 'tel' || type === 'url') {
466
+ if (minLength !== undefined) inputEl.minLength = minLength;
467
+ if (maxLength !== undefined) inputEl.maxLength = maxLength;
468
+ if (pattern) inputEl.pattern = pattern;
469
+ }
230
470
  }
231
471
 
232
472
  inputEl.className = 'jux-input-element';
@@ -237,19 +477,122 @@ export class Input {
237
477
  inputEl.required = required;
238
478
  inputEl.disabled = disabled;
239
479
 
480
+ // Input event handler
240
481
  inputEl.addEventListener('input', (e) => {
241
482
  const target = e.target as HTMLInputElement | HTMLTextAreaElement;
242
483
  this.state.value = target.value;
484
+
485
+ // Clear error on input
486
+ this._clearError();
487
+
243
488
  if (this._onChange) {
244
489
  this._onChange(target.value);
245
490
  }
246
491
  });
247
492
 
493
+ // Blur event for validation
494
+ inputEl.addEventListener('blur', () => {
495
+ this.validate();
496
+ });
497
+
248
498
  wrapper.appendChild(inputEl);
499
+
500
+ // Error message element
501
+ const errorEl = document.createElement('div');
502
+ errorEl.className = 'jux-input-error';
503
+ errorEl.id = `${this._id}-error`;
504
+ errorEl.style.display = 'none';
505
+ wrapper.appendChild(errorEl);
506
+
507
+ // Character counter for maxLength
508
+ if (maxLength && (type === 'text' || type === 'textarea')) {
509
+ const counterEl = document.createElement('div');
510
+ counterEl.className = 'jux-input-counter';
511
+ counterEl.id = `${this._id}-counter`;
512
+ counterEl.textContent = `${value.length}/${maxLength}`;
513
+
514
+ inputEl.addEventListener('input', () => {
515
+ counterEl.textContent = `${inputEl.value.length}/${maxLength}`;
516
+ });
517
+
518
+ wrapper.appendChild(counterEl);
519
+ }
520
+
249
521
  container.appendChild(wrapper);
522
+
523
+ // Add default styles if not already present
524
+ this._injectDefaultStyles();
525
+
250
526
  return this;
251
527
  }
252
528
 
529
+ private _injectDefaultStyles(): void {
530
+ const styleId = 'jux-input-styles';
531
+ if (document.getElementById(styleId)) return;
532
+
533
+ const style = document.createElement('style');
534
+ style.id = styleId;
535
+ style.textContent = `
536
+ .jux-input {
537
+ margin-bottom: 16px;
538
+ }
539
+
540
+ .jux-input-label {
541
+ display: block;
542
+ margin-bottom: 6px;
543
+ font-weight: 500;
544
+ color: #374151;
545
+ }
546
+
547
+ .jux-input-required {
548
+ color: #ef4444;
549
+ }
550
+
551
+ .jux-input-element {
552
+ width: 100%;
553
+ padding: 8px 12px;
554
+ border: 1px solid #d1d5db;
555
+ border-radius: 6px;
556
+ font-size: 14px;
557
+ transition: border-color 0.2s;
558
+ box-sizing: border-box;
559
+ }
560
+
561
+ .jux-input-element:focus {
562
+ outline: none;
563
+ border-color: #3b82f6;
564
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
565
+ }
566
+
567
+ .jux-input-element:disabled {
568
+ background-color: #f3f4f6;
569
+ cursor: not-allowed;
570
+ }
571
+
572
+ .jux-input-element.jux-input-invalid {
573
+ border-color: #ef4444;
574
+ }
575
+
576
+ .jux-input-element.jux-input-invalid:focus {
577
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
578
+ }
579
+
580
+ .jux-input-error {
581
+ color: #ef4444;
582
+ font-size: 12px;
583
+ margin-top: 4px;
584
+ }
585
+
586
+ .jux-input-counter {
587
+ text-align: right;
588
+ font-size: 12px;
589
+ color: #6b7280;
590
+ margin-top: 4px;
591
+ }
592
+ `;
593
+ document.head.appendChild(style);
594
+ }
595
+
253
596
  renderTo(juxComponent: any): this {
254
597
  if (!juxComponent?._id) {
255
598
  throw new Error('Input.renderTo: Invalid component');
@@ -1,22 +1,17 @@
1
1
  import { getOrCreateContainer } from './helpers.js';
2
2
  import { State } from '../reactivity/state.js';
3
3
 
4
- /**
5
- * Select option
6
- */
7
4
  export interface SelectOption {
8
5
  label: string;
9
6
  value: string;
10
7
  disabled?: boolean;
11
8
  }
12
9
 
13
- /**
14
- * Select component options
15
- */
16
10
  export interface SelectOptions {
17
11
  options?: SelectOption[];
18
12
  value?: string;
19
13
  placeholder?: string;
14
+ label?: string;
20
15
  disabled?: boolean;
21
16
  name?: string;
22
17
  onChange?: (value: string) => void;
@@ -24,36 +19,17 @@ export interface SelectOptions {
24
19
  class?: string;
25
20
  }
26
21
 
27
- /**
28
- * Select component state
29
- */
30
22
  type SelectState = {
31
23
  options: SelectOption[];
32
24
  value: string;
33
25
  placeholder: string;
26
+ label: string;
34
27
  disabled: boolean;
35
28
  name: string;
36
29
  style: string;
37
30
  class: string;
38
31
  };
39
32
 
40
- /**
41
- * Select component - Dropdown select
42
- *
43
- * Usage:
44
- * jux.select('country', {
45
- * placeholder: 'Select country',
46
- * options: [
47
- * { label: 'USA', value: 'us' },
48
- * { label: 'Canada', value: 'ca' }
49
- * ],
50
- * onChange: (val) => console.log(val)
51
- * }).render('#form');
52
- *
53
- * // Two-way binding
54
- * const countryState = state('us');
55
- * jux.select('country').bind(countryState).render('#form');
56
- */
57
33
  export class Select {
58
34
  state: SelectState;
59
35
  container: HTMLElement | null = null;
@@ -71,6 +47,7 @@ export class Select {
71
47
  options: options.options ?? [],
72
48
  value: options.value ?? '',
73
49
  placeholder: options.placeholder ?? 'Select...',
50
+ label: options.label ?? '',
74
51
  disabled: options.disabled ?? false,
75
52
  name: options.name ?? id,
76
53
  style: options.style ?? '',
@@ -78,10 +55,6 @@ export class Select {
78
55
  };
79
56
  }
80
57
 
81
- /* -------------------------
82
- * Fluent API
83
- * ------------------------- */
84
-
85
58
  options(value: SelectOption[]): this {
86
59
  this.state.options = value;
87
60
  return this;
@@ -103,6 +76,11 @@ export class Select {
103
76
  return this;
104
77
  }
105
78
 
79
+ label(value: string): this {
80
+ this.state.label = value;
81
+ return this;
82
+ }
83
+
106
84
  disabled(value: boolean): this {
107
85
  this.state.disabled = value;
108
86
  this._updateElement();
@@ -142,15 +120,17 @@ export class Select {
142
120
  });
143
121
 
144
122
  // Update state when select changes
145
- this.onChange((value) => stateObj.set(value));
123
+ const originalOnChange = this._onChange;
124
+ this._onChange = (value) => {
125
+ stateObj.set(value);
126
+ if (originalOnChange) {
127
+ originalOnChange(value);
128
+ }
129
+ };
146
130
 
147
131
  return this;
148
132
  }
149
133
 
150
- /* -------------------------
151
- * Helpers
152
- * ------------------------- */
153
-
154
134
  private _updateElement(): void {
155
135
  const select = document.getElementById(`${this._id}-select`) as HTMLSelectElement;
156
136
  if (select) {
@@ -163,10 +143,6 @@ export class Select {
163
143
  return this.state.value;
164
144
  }
165
145
 
166
- /* -------------------------
167
- * Render
168
- * ------------------------- */
169
-
170
146
  render(targetId?: string): this {
171
147
  let container: HTMLElement;
172
148
 
@@ -181,7 +157,7 @@ export class Select {
181
157
  }
182
158
 
183
159
  this.container = container;
184
- const { options, value, placeholder, disabled, name, style, class: className } = this.state;
160
+ const { options, value, placeholder, label, disabled, name, style, class: className } = this.state;
185
161
 
186
162
  const wrapper = document.createElement('div');
187
163
  wrapper.className = 'jux-select';
@@ -195,6 +171,15 @@ export class Select {
195
171
  wrapper.setAttribute('style', style);
196
172
  }
197
173
 
174
+ // Label
175
+ if (label) {
176
+ const labelEl = document.createElement('label');
177
+ labelEl.className = 'jux-select-label';
178
+ labelEl.htmlFor = `${this._id}-select`;
179
+ labelEl.textContent = label;
180
+ wrapper.appendChild(labelEl);
181
+ }
182
+
198
183
  const select = document.createElement('select');
199
184
  select.className = 'jux-select-element';
200
185
  select.id = `${this._id}-select`;
@@ -231,9 +216,57 @@ export class Select {
231
216
 
232
217
  wrapper.appendChild(select);
233
218
  container.appendChild(wrapper);
219
+
220
+ // Add default styles if not already present
221
+ this._injectDefaultStyles();
222
+
234
223
  return this;
235
224
  }
236
225
 
226
+ private _injectDefaultStyles(): void {
227
+ const styleId = 'jux-select-styles';
228
+ if (document.getElementById(styleId)) return;
229
+
230
+ const style = document.createElement('style');
231
+ style.id = styleId;
232
+ style.textContent = `
233
+ .jux-select {
234
+ margin-bottom: 16px;
235
+ }
236
+
237
+ .jux-select-label {
238
+ display: block;
239
+ margin-bottom: 6px;
240
+ font-weight: 500;
241
+ color: #374151;
242
+ }
243
+
244
+ .jux-select-element {
245
+ width: 100%;
246
+ padding: 8px 12px;
247
+ border: 1px solid #d1d5db;
248
+ border-radius: 6px;
249
+ font-size: 14px;
250
+ background: white;
251
+ cursor: pointer;
252
+ transition: border-color 0.2s;
253
+ box-sizing: border-box;
254
+ }
255
+
256
+ .jux-select-element:focus {
257
+ outline: none;
258
+ border-color: #3b82f6;
259
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
260
+ }
261
+
262
+ .jux-select-element:disabled {
263
+ background-color: #f3f4f6;
264
+ cursor: not-allowed;
265
+ }
266
+ `;
267
+ document.head.appendChild(style);
268
+ }
269
+
237
270
  renderTo(juxComponent: any): this {
238
271
  if (!juxComponent?._id) {
239
272
  throw new Error('Select.renderTo: Invalid component');
@@ -49,7 +49,7 @@ export class Sidebar {
49
49
 
50
50
  this.state = {
51
51
  title: options.title ?? '',
52
- width: options.width ?? '250px',
52
+ width: options.width ?? '300px',
53
53
  position: options.position ?? 'left',
54
54
  collapsible: options.collapsible ?? false,
55
55
  collapsed: options.collapsed ?? false,