ripple 0.2.172 → 0.2.173

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.172",
6
+ "version": "0.2.173",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -81,6 +81,6 @@
81
81
  "typescript": "^5.9.2"
82
82
  },
83
83
  "peerDependencies": {
84
- "ripple": "0.2.172"
84
+ "ripple": "0.2.173"
85
85
  }
86
86
  }
@@ -88,6 +88,7 @@ export {
88
88
  bindContentBoxSize,
89
89
  bindBorderBoxSize,
90
90
  bindDevicePixelContentBoxSize,
91
+ bindFiles,
91
92
  bindIndeterminate,
92
93
  bindInnerHTML,
93
94
  bindInnerText,
@@ -311,32 +311,28 @@ export function bindGroup(maybe_tracked) {
311
311
  return (input) => {
312
312
  var is_checkbox = input.getAttribute('type') === 'checkbox';
313
313
 
314
- // Store the input's value
315
- // @ts-ignore
316
- input.__value = input.value;
317
-
318
314
  var clear_event = on(input, 'change', () => {
319
- // @ts-ignore
320
- var value = input.__value;
315
+ var value = input.value;
316
+ var result;
321
317
 
322
318
  if (is_checkbox) {
323
319
  /** @type {Array<any>} */
324
- var current_value = get(tracked) || [];
320
+ var list = get(tracked) || [];
325
321
 
326
322
  if (input.checked) {
327
- // Add if not already present
328
- if (!current_value.includes(value)) {
329
- value = [...current_value, value];
323
+ if (!list.includes(value)) {
324
+ result = [...list, value];
330
325
  } else {
331
- value = current_value;
326
+ result = list;
332
327
  }
333
328
  } else {
334
- // Remove the value
335
- value = current_value.filter((v) => v !== value);
329
+ result = list.filter((v) => v !== value);
336
330
  }
331
+ } else {
332
+ result = input.value;
337
333
  }
338
334
 
339
- set(tracked, value);
335
+ set(tracked, result);
340
336
  });
341
337
 
342
338
  effect(() => {
@@ -344,11 +340,9 @@ export function bindGroup(maybe_tracked) {
344
340
 
345
341
  if (is_checkbox) {
346
342
  value = value || [];
347
- // @ts-ignore
348
- input.checked = value.includes(input.__value);
343
+ input.checked = value.includes(input.value);
349
344
  } else {
350
- // @ts-ignore
351
- input.checked = value === input.__value;
345
+ input.checked = value === input.value;
352
346
  }
353
347
  });
354
348
 
@@ -530,6 +524,24 @@ export function bindTextContent(maybe_tracked) {
530
524
  return bind_content_editable(maybe_tracked, 'textContent');
531
525
  }
532
526
 
527
+ /**
528
+ * @param {unknown} maybe_tracked
529
+ * @returns {(node: HTMLInputElement) => void}
530
+ */
531
+ export function bindFiles(maybe_tracked) {
532
+ if (!is_tracked_object(maybe_tracked)) {
533
+ throw not_tracked_type_error('bindFiles()');
534
+ }
535
+
536
+ const tracked = /** @type {Tracked} */ (maybe_tracked);
537
+
538
+ return (input) => {
539
+ return on(input, 'change', () => {
540
+ set(tracked, input.files);
541
+ });
542
+ };
543
+ }
544
+
533
545
  /**
534
546
  * Syntactic sugar for binding a HTMLElement with {ref fn}
535
547
  * @param {unknown} maybe_tracked
@@ -129,7 +129,7 @@ function apply_styles(element, new_styles, prev) {
129
129
  * @param {string} key
130
130
  * @param {any} value
131
131
  * @param {Record<string, (() => void) | undefined>} remove_listeners
132
- * @param {Record<string, any>} prev
132
+ * @param {Record<string | symbol, any>} prev
133
133
  */
134
134
  function set_attribute_helper(element, key, value, remove_listeners, prev) {
135
135
  if (key === 'class') {
@@ -18,6 +18,7 @@ import {
18
18
  bindInnerText,
19
19
  bindTextContent,
20
20
  bindNode,
21
+ bindFiles,
21
22
  } from 'ripple';
22
23
 
23
24
  // Mock ResizeObserver for testing
@@ -57,9 +58,37 @@ function triggerResize(element: Element, entry: Partial<ResizeObserverEntry>) {
57
58
  }
58
59
  }
59
60
 
61
+ // Mock DataTransfer for testing file inputs
62
+ class MockDataTransfer {
63
+ items: MockDataTransferItemList;
64
+ files: FileList;
65
+
66
+ constructor() {
67
+ this.items = new MockDataTransferItemList();
68
+ this.files = this.items.files;
69
+ }
70
+ }
71
+
72
+ class MockDataTransferItemList {
73
+ _files: File[] = [];
74
+
75
+ get files(): FileList {
76
+ return this._files as any as FileList;
77
+ }
78
+
79
+ add(file: File): void {
80
+ this._files.push(file);
81
+ }
82
+
83
+ get length(): number {
84
+ return this._files.length;
85
+ }
86
+ }
87
+
60
88
  // Setup ResizeObserver mock
61
89
  beforeAll(() => {
62
90
  (global as any).ResizeObserver = createMockResizeObserver;
91
+ (global as any).DataTransfer = MockDataTransfer;
63
92
  });
64
93
 
65
94
  afterAll(() => {
@@ -1227,6 +1256,265 @@ describe('bindNode', () => {
1227
1256
  });
1228
1257
  });
1229
1258
 
1259
+ describe('bindFiles', () => {
1260
+ it('should bind files from file input', () => {
1261
+ const logs: FileList[] = [];
1262
+
1263
+ component App() {
1264
+ const files = track(null);
1265
+
1266
+ effect(() => {
1267
+ @files;
1268
+ if (@files) logs.push(@files);
1269
+ });
1270
+
1271
+ <input type="file" multiple {ref bindFiles(files)} />
1272
+ }
1273
+
1274
+ render(App);
1275
+ flushSync();
1276
+
1277
+ const input = container.querySelector('input') as HTMLInputElement;
1278
+
1279
+ // Create mock FileList using DataTransfer
1280
+ const dt = new DataTransfer();
1281
+ const file1 = new File(['content1'], 'file1.txt', { type: 'text/plain' });
1282
+ const file2 = new File(['content2'], 'file2.txt', { type: 'text/plain' });
1283
+ dt.items.add(file1);
1284
+ dt.items.add(file2);
1285
+
1286
+ // Simulate file selection
1287
+ Object.defineProperty(input, 'files', {
1288
+ value: dt.files,
1289
+ writable: true,
1290
+ });
1291
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1292
+ flushSync();
1293
+
1294
+ expect(logs.length).toBeGreaterThan(0);
1295
+ const lastFiles = logs[logs.length - 1];
1296
+ expect(lastFiles.length).toBe(2);
1297
+ expect(lastFiles[0].name).toBe('file1.txt');
1298
+ expect(lastFiles[1].name).toBe('file2.txt');
1299
+ });
1300
+
1301
+ it('should update tracked value when files are selected', () => {
1302
+ let capturedFiles: FileList | null = null;
1303
+
1304
+ component App() {
1305
+ const files = track(null);
1306
+
1307
+ effect(() => {
1308
+ capturedFiles = @files;
1309
+ });
1310
+
1311
+ <input type="file" {ref bindFiles(files)} />
1312
+ }
1313
+
1314
+ render(App);
1315
+ flushSync();
1316
+
1317
+ const input = container.querySelector('input') as HTMLInputElement;
1318
+
1319
+ // Create mock file
1320
+ const dt = new DataTransfer();
1321
+ const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
1322
+ dt.items.add(file);
1323
+
1324
+ Object.defineProperty(input, 'files', {
1325
+ value: dt.files,
1326
+ writable: true,
1327
+ });
1328
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1329
+ flushSync();
1330
+
1331
+ expect(capturedFiles).not.toBeNull();
1332
+ expect(capturedFiles?.length).toBe(1);
1333
+ expect(capturedFiles?.[0].name).toBe('test.txt');
1334
+ });
1335
+
1336
+ it('should allow clearing files via input.files', () => {
1337
+ let capturedFiles: FileList | null = null;
1338
+
1339
+ component App() {
1340
+ const files = track(null);
1341
+ const input = track(null);
1342
+
1343
+ effect(() => {
1344
+ capturedFiles = @files;
1345
+ });
1346
+
1347
+ <div>
1348
+ <input type="file" {ref bindFiles(files)} {ref bindNode(input)} />
1349
+ <button
1350
+ onClick={() => {
1351
+ if (@input) {
1352
+ @input.files = new DataTransfer().files;
1353
+ @input.dispatchEvent(new Event('change', { bubbles: true }));
1354
+ }
1355
+ }}
1356
+ >
1357
+ {'Clear'}
1358
+ </button>
1359
+ </div>
1360
+ }
1361
+
1362
+ render(App);
1363
+ flushSync();
1364
+
1365
+ const input = container.querySelector('input') as HTMLInputElement;
1366
+ const button = container.querySelector('button') as HTMLButtonElement;
1367
+
1368
+ // Add a file first
1369
+ const dt = new DataTransfer();
1370
+ const file = new File(['content'], 'file.txt', { type: 'text/plain' });
1371
+ dt.items.add(file);
1372
+
1373
+ Object.defineProperty(input, 'files', {
1374
+ value: dt.files,
1375
+ writable: true,
1376
+ });
1377
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1378
+ flushSync();
1379
+
1380
+ expect(capturedFiles?.length).toBe(1);
1381
+
1382
+ // Clear via button
1383
+ button.click();
1384
+ flushSync();
1385
+
1386
+ expect(capturedFiles?.length).toBe(0);
1387
+ });
1388
+
1389
+ it('should handle multiple file selections', () => {
1390
+ const fileCounts: number[] = [];
1391
+
1392
+ component App() {
1393
+ const files = track(null);
1394
+
1395
+ effect(() => {
1396
+ @files;
1397
+ if (@files) {
1398
+ fileCounts.push(@files.length);
1399
+ }
1400
+ });
1401
+
1402
+ <input type="file" multiple {ref bindFiles(files)} />
1403
+ }
1404
+
1405
+ render(App);
1406
+ flushSync();
1407
+
1408
+ const input = container.querySelector('input') as HTMLInputElement;
1409
+
1410
+ // First selection: 2 files
1411
+ const dt1 = new DataTransfer();
1412
+ dt1.items.add(new File(['a'], 'a.txt'));
1413
+ dt1.items.add(new File(['b'], 'b.txt'));
1414
+
1415
+ Object.defineProperty(input, 'files', {
1416
+ value: dt1.files,
1417
+ writable: true,
1418
+ });
1419
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1420
+ flushSync();
1421
+
1422
+ // Second selection: 3 files
1423
+ const dt2 = new DataTransfer();
1424
+ dt2.items.add(new File(['x'], 'x.txt'));
1425
+ dt2.items.add(new File(['y'], 'y.txt'));
1426
+ dt2.items.add(new File(['z'], 'z.txt'));
1427
+
1428
+ Object.defineProperty(input, 'files', {
1429
+ value: dt2.files,
1430
+ writable: true,
1431
+ });
1432
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1433
+ flushSync();
1434
+
1435
+ expect(fileCounts).toEqual([2, 3]);
1436
+ });
1437
+
1438
+ it('should handle file input without multiple attribute', () => {
1439
+ let capturedFile: File | null = null;
1440
+
1441
+ component App() {
1442
+ const files = track(null);
1443
+
1444
+ effect(() => {
1445
+ @files;
1446
+ if (@files && @files.length > 0) {
1447
+ capturedFile = @files[0];
1448
+ }
1449
+ });
1450
+
1451
+ <input type="file" {ref bindFiles(files)} />
1452
+ }
1453
+
1454
+ render(App);
1455
+ flushSync();
1456
+
1457
+ const input = container.querySelector('input') as HTMLInputElement;
1458
+
1459
+ const dt = new DataTransfer();
1460
+ const file = new File(['single file content'], 'single.pdf', { type: 'application/pdf' });
1461
+ dt.items.add(file);
1462
+
1463
+ Object.defineProperty(input, 'files', {
1464
+ value: dt.files,
1465
+ writable: true,
1466
+ });
1467
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1468
+ flushSync();
1469
+
1470
+ expect(capturedFile).not.toBeNull();
1471
+ expect(capturedFile?.name).toBe('single.pdf');
1472
+ expect(capturedFile?.type).toBe('application/pdf');
1473
+ });
1474
+
1475
+ it('should handle empty file selection', () => {
1476
+ const logs: (FileList | null)[] = [];
1477
+
1478
+ component App() {
1479
+ const files = track(null);
1480
+
1481
+ effect(() => {
1482
+ logs.push(@files);
1483
+ });
1484
+
1485
+ <input type="file" {ref bindFiles(files)} />
1486
+ }
1487
+
1488
+ render(App);
1489
+ flushSync();
1490
+
1491
+ const input = container.querySelector('input') as HTMLInputElement;
1492
+
1493
+ // Select a file
1494
+ const dt = new DataTransfer();
1495
+ dt.items.add(new File(['test'], 'test.txt'));
1496
+
1497
+ Object.defineProperty(input, 'files', {
1498
+ value: dt.files,
1499
+ writable: true,
1500
+ });
1501
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1502
+ flushSync();
1503
+
1504
+ // Clear selection
1505
+ Object.defineProperty(input, 'files', {
1506
+ value: new DataTransfer().files,
1507
+ writable: true,
1508
+ });
1509
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1510
+ flushSync();
1511
+
1512
+ expect(logs.length).toBeGreaterThan(1);
1513
+ const lastFiles = logs[logs.length - 1];
1514
+ expect(lastFiles?.length).toBe(0);
1515
+ });
1516
+ });
1517
+
1230
1518
  describe('error handling', () => {
1231
1519
  it('should throw error when bindValue receives non-tracked object', () => {
1232
1520
  expect(() => {
@@ -1371,4 +1659,13 @@ describe('error handling', () => {
1371
1659
  render(App);
1372
1660
  }).toThrow('bindNode() argument is not a tracked object');
1373
1661
  });
1662
+
1663
+ it('should throw error when bindFiles receives non-tracked object', () => {
1664
+ expect(() => {
1665
+ component App() {
1666
+ <input type="file" {ref bindFiles({ value: null })} />
1667
+ }
1668
+ render(App);
1669
+ }).toThrow('bindFiles() argument is not a tracked object');
1670
+ });
1374
1671
  });
package/types/index.d.ts CHANGED
@@ -307,3 +307,5 @@ export declare function bindOffsetHeight<V>(tracked: Tracked<V>): (node: HTMLEle
307
307
  export declare function bindOffsetWidth<V>(tracked: Tracked<V>): (node: HTMLElement) => void;
308
308
 
309
309
  export declare function bindIndeterminate<V>(tracked: Tracked<V>): (node: HTMLInputElement) => void;
310
+
311
+ export declare function bindFiles<V>(tracked: Tracked<V>): (node: HTMLInputElement) => void;