ripple 0.2.115 → 0.2.116

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.
@@ -0,0 +1,443 @@
1
+ /** @import { Block, Tracked } from '#client' */
2
+
3
+ import { effect, render } from './blocks.js';
4
+ import { on } from './events.js';
5
+ import { active_block, get, set, tick, untrack } from './runtime.js';
6
+ import { is_array, is_tracked_object } from './utils.js';
7
+
8
+ /**
9
+ * Resize observer singleton.
10
+ * One listener per element only!
11
+ * https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ
12
+ */
13
+ class ResizeObserverSingleton {
14
+ /** */
15
+ #listeners = new WeakMap();
16
+
17
+ /** @type {ResizeObserver | undefined} */
18
+ #observer;
19
+
20
+ /** @type {ResizeObserverOptions} */
21
+ #options;
22
+
23
+ /** @static */
24
+ static entries = new WeakMap();
25
+
26
+ /** @param {ResizeObserverOptions} options */
27
+ constructor(options) {
28
+ this.#options = options;
29
+ }
30
+
31
+ /**
32
+ * @param {Element} element
33
+ * @param {(entry: ResizeObserverEntry) => any} listener
34
+ */
35
+ observe(element, listener) {
36
+ var listeners = this.#listeners.get(element) || new Set();
37
+ listeners.add(listener);
38
+
39
+ this.#listeners.set(element, listeners);
40
+ this.#getObserver().observe(element, this.#options);
41
+
42
+ return () => {
43
+ var listeners = this.#listeners.get(element);
44
+ listeners.delete(listener);
45
+
46
+ if (listeners.size === 0) {
47
+ this.#listeners.delete(element);
48
+ /** @type {ResizeObserver} */ (this.#observer).unobserve(element);
49
+ }
50
+ };
51
+ }
52
+
53
+ #getObserver() {
54
+ return (
55
+ this.#observer ??
56
+ (this.#observer = new ResizeObserver(
57
+ /** @param {any} entries */ (entries) => {
58
+ for (var entry of entries) {
59
+ ResizeObserverSingleton.entries.set(entry.target, entry);
60
+ for (var listener of this.#listeners.get(entry.target) || []) {
61
+ listener(entry);
62
+ }
63
+ }
64
+ },
65
+ ))
66
+ );
67
+ }
68
+ }
69
+
70
+ var resize_observer_content_box = /* @__PURE__ */ new ResizeObserverSingleton({
71
+ box: 'content-box',
72
+ });
73
+
74
+ var resize_observer_border_box = /* @__PURE__ */ new ResizeObserverSingleton({
75
+ box: 'border-box',
76
+ });
77
+
78
+ var resize_observer_device_pixel_content_box = /* @__PURE__ */ new ResizeObserverSingleton({
79
+ box: 'device-pixel-content-box',
80
+ });
81
+
82
+ /**
83
+ * @param {string} value
84
+ */
85
+ function to_number(value) {
86
+ return value === '' ? null : +value;
87
+ }
88
+
89
+ /**
90
+ * @param {HTMLInputElement} input
91
+ */
92
+ function is_numberlike_input(input) {
93
+ var type = input.type;
94
+ return type === 'number' || type === 'range';
95
+ }
96
+
97
+ /** @param {HTMLOptionElement} option */
98
+ function get_option_value(option) {
99
+ return option.value;
100
+ }
101
+
102
+ /**
103
+ * Selects the correct option(s) (depending on whether this is a multiple select)
104
+ * @template V
105
+ * @param {HTMLSelectElement} select
106
+ * @param {V} value
107
+ * @param {boolean} mounting
108
+ */
109
+ function select_option(select, value, mounting = false) {
110
+ if (select.multiple) {
111
+ // If value is null or undefined, keep the selection as is
112
+ if (value == undefined) {
113
+ return;
114
+ }
115
+
116
+ // If not an array, warn and keep the selection as is
117
+ if (!is_array(value)) {
118
+ // TODO
119
+ }
120
+
121
+ // Otherwise, update the selection
122
+ for (var option of select.options) {
123
+ option.selected = /** @type {string[]} */ (value).includes(get_option_value(option));
124
+ }
125
+
126
+ return;
127
+ }
128
+
129
+ for (option of select.options) {
130
+ var option_value = get_option_value(option);
131
+ if (option_value === value) {
132
+ option.selected = true;
133
+ return;
134
+ }
135
+ }
136
+
137
+ if (!mounting || value !== undefined) {
138
+ select.selectedIndex = -1; // no option should be selected
139
+ }
140
+ }
141
+
142
+ /**
143
+ * @param {unknown} maybe_tracked
144
+ * @returns {(node: HTMLInputElement | HTMLSelectElement) => void}
145
+ */
146
+ export function bindValue(maybe_tracked) {
147
+ if (!is_tracked_object(maybe_tracked)) {
148
+ throw new TypeError('bindValue() argument is not a tracked object');
149
+ }
150
+
151
+ var block = /** @type {Block} */ (active_block);
152
+ var tracked = /** @type {Tracked} */ (maybe_tracked);
153
+
154
+ return (node) => {
155
+ var clear_event;
156
+
157
+ if (node.tagName === 'SELECT') {
158
+ var select = /** @type {HTMLSelectElement} */ (node);
159
+ var mounting = true;
160
+
161
+ clear_event = on(select, 'change', async () => {
162
+ var query = ':checked';
163
+ /** @type {unknown} */
164
+ var value;
165
+
166
+ if (select.multiple) {
167
+ value = [].map.call(select.querySelectorAll(query), get_option_value);
168
+ } else {
169
+ /** @type {HTMLOptionElement | null} */
170
+ var selected_option =
171
+ select.querySelector(query) ??
172
+ // will fall back to first non-disabled option if no option is selected
173
+ select.querySelector('option:not([disabled])');
174
+ value = selected_option && get_option_value(selected_option);
175
+ }
176
+
177
+ set(tracked, value, block);
178
+ });
179
+
180
+ effect(() => {
181
+ var value = get(tracked);
182
+ select_option(select, value, mounting);
183
+
184
+ // Mounting and value undefined -> take selection from dom
185
+ if (mounting && value === undefined) {
186
+ /** @type {HTMLOptionElement | null} */
187
+ var selected_option = select.querySelector(':checked');
188
+ if (selected_option !== null) {
189
+ value = get_option_value(selected_option);
190
+ set(tracked, value, block);
191
+ }
192
+ }
193
+
194
+ mounting = false;
195
+ });
196
+ } else {
197
+ var input = /** @type {HTMLInputElement} */ (node);
198
+
199
+ clear_event = on(input, 'input', async () => {
200
+ /** @type {any} */
201
+ var value = input.value;
202
+ value = is_numberlike_input(input) ? to_number(value) : value;
203
+ set(tracked, value, block);
204
+
205
+ await tick();
206
+
207
+ if (value !== (value = get(tracked))) {
208
+ var start = input.selectionStart;
209
+ var end = input.selectionEnd;
210
+ input.value = value ?? '';
211
+
212
+ // Restore selection
213
+ if (end !== null) {
214
+ input.selectionStart = start;
215
+ input.selectionEnd = Math.min(end, input.value.length);
216
+ }
217
+ }
218
+ });
219
+
220
+ render(() => {
221
+ var value = get(tracked);
222
+
223
+ if (is_numberlike_input(input) && value === to_number(input.value)) {
224
+ return;
225
+ }
226
+
227
+ if (input.type === 'date' && !value && !input.value) {
228
+ return;
229
+ }
230
+
231
+ if (value !== input.value) {
232
+ input.value = value ?? '';
233
+ }
234
+ });
235
+
236
+ return clear_event;
237
+ }
238
+ };
239
+ }
240
+
241
+ /**
242
+ * @param {unknown} maybe_tracked
243
+ * @returns {(node: HTMLInputElement) => void}
244
+ */
245
+ export function bindChecked(maybe_tracked) {
246
+ if (!is_tracked_object(maybe_tracked)) {
247
+ throw new TypeError('bindChecked() argument is not a tracked object');
248
+ }
249
+
250
+ const block = /** @type {any} */ (active_block);
251
+ const tracked = /** @type {Tracked} */ (maybe_tracked);
252
+
253
+ return (input) => {
254
+ const clear_event = on(input, 'change', () => {
255
+ set(tracked, input.checked, block);
256
+ });
257
+
258
+ return clear_event;
259
+ };
260
+ }
261
+
262
+ /**
263
+ * @param {unknown} maybe_tracked
264
+ * @param {'clientWidth' | 'clientHeight' | 'offsetWidth' | 'offsetHeight'} type
265
+ */
266
+ function bind_element_size(maybe_tracked, type) {
267
+ if (!is_tracked_object(maybe_tracked)) {
268
+ throw new TypeError(
269
+ `bind${type.charAt(0).toUpperCase() + type.slice(1)}() argument is not a tracked object`,
270
+ );
271
+ }
272
+
273
+ var block = /** @type {any} */ (active_block);
274
+ var tracked = /** @type {Tracked<any>} */ (maybe_tracked);
275
+
276
+ return (/** @type {HTMLElement} */ element) => {
277
+ var unsubscribe = resize_observer_border_box.observe(element, () =>
278
+ set(tracked, element[type], block),
279
+ );
280
+
281
+ effect(() => {
282
+ untrack(() => set(tracked, element[type], block));
283
+ return unsubscribe;
284
+ });
285
+ };
286
+ }
287
+
288
+ /**
289
+ * @param {unknown} maybe_tracked
290
+ * @returns {(node: HTMLElement) => void}
291
+ */
292
+ export function bindClientWidth(maybe_tracked) {
293
+ return bind_element_size(maybe_tracked, 'clientWidth');
294
+ }
295
+
296
+ /**
297
+ * @param {unknown} maybe_tracked
298
+ * @returns {(node: HTMLElement) => void}
299
+ */
300
+ export function bindClientHeight(maybe_tracked) {
301
+ return bind_element_size(maybe_tracked, 'clientHeight');
302
+ }
303
+
304
+ /**
305
+ * @param {unknown} maybe_tracked
306
+ * @returns {(node: HTMLElement) => void}
307
+ */
308
+ export function bindOffsetWidth(maybe_tracked) {
309
+ return bind_element_size(maybe_tracked, 'offsetWidth');
310
+ }
311
+
312
+ /**
313
+ * @param {unknown} maybe_tracked
314
+ * @returns {(node: HTMLElement) => void}
315
+ */
316
+ export function bindOffsetHeight(maybe_tracked) {
317
+ return bind_element_size(maybe_tracked, 'offsetHeight');
318
+ }
319
+
320
+ /**
321
+ * @param {unknown} maybe_tracked
322
+ * @param {'contentRect' | 'contentBoxSize' | 'borderBoxSize' | 'devicePixelContentBoxSize'} type
323
+ */
324
+ function bind_element_rect(maybe_tracked, type) {
325
+ if (!is_tracked_object(maybe_tracked)) {
326
+ throw new TypeError(
327
+ `bind${type.charAt(0).toUpperCase() + type.slice(1)}() argument is not a tracked object`,
328
+ );
329
+ }
330
+
331
+ var block = /** @type {any} */ (active_block);
332
+ var tracked = /** @type {Tracked<any>} */ (maybe_tracked);
333
+ var observer =
334
+ type === 'contentRect' || type === 'contentBoxSize'
335
+ ? resize_observer_content_box
336
+ : type === 'borderBoxSize'
337
+ ? resize_observer_border_box
338
+ : resize_observer_device_pixel_content_box;
339
+
340
+ return (/** @type {HTMLElement} */ element) => {
341
+ var unsubscribe = observer.observe(
342
+ element,
343
+ /** @param {any} entry */ (entry) => set(tracked, entry[type], block),
344
+ );
345
+
346
+ effect(() => unsubscribe);
347
+ };
348
+ }
349
+
350
+ /**
351
+ * @param {unknown} maybe_tracked
352
+ * @returns {(node: HTMLElement) => void}
353
+ */
354
+ export function bindContentRect(maybe_tracked) {
355
+ return bind_element_rect(maybe_tracked, 'contentRect');
356
+ }
357
+
358
+ /**
359
+ * @param {unknown} maybe_tracked
360
+ * @returns {(node: HTMLElement) => void}
361
+ */
362
+ export function bindContentBoxSize(maybe_tracked) {
363
+ return bind_element_rect(maybe_tracked, 'contentBoxSize');
364
+ }
365
+
366
+ /**
367
+ * @param {unknown} maybe_tracked
368
+ * @returns {(node: HTMLElement) => void}
369
+ */
370
+ export function bindBorderBoxSize(maybe_tracked) {
371
+ return bind_element_rect(maybe_tracked, 'borderBoxSize');
372
+ }
373
+
374
+ /**
375
+ * @param {unknown} maybe_tracked
376
+ * @returns {(node: HTMLElement) => void}
377
+ */
378
+ export function bindDevicePixelContentBoxSize(maybe_tracked) {
379
+ return bind_element_rect(maybe_tracked, 'devicePixelContentBoxSize');
380
+ }
381
+
382
+ /**
383
+ * @param {unknown} maybe_tracked
384
+ * @param {'innerHTML' | 'innerText' | 'textContent'} property
385
+ * @returns {(node: HTMLElement) => void}
386
+ */
387
+ export function bind_content_editable(maybe_tracked, property) {
388
+ if (!is_tracked_object(maybe_tracked)) {
389
+ throw new TypeError(
390
+ `bind${property.charAt(0).toUpperCase() + property.slice(1)}() argument is not a tracked object`,
391
+ );
392
+ }
393
+
394
+ const block = /** @type {any} */ (active_block);
395
+ const tracked = /** @type {Tracked} */ (maybe_tracked);
396
+
397
+ return (element) => {
398
+ const clear_event = on(element, 'input', () => {
399
+ set(tracked, element[property], block);
400
+ });
401
+
402
+ render(() => {
403
+ var value = get(tracked);
404
+
405
+ if (element[property] !== value) {
406
+ if (value == null) {
407
+ // @ts-ignore
408
+ var non_null_value = element[property];
409
+ set(tracked, non_null_value, block);
410
+ } else {
411
+ // @ts-ignore
412
+ element[property] = value + '';
413
+ }
414
+ }
415
+ });
416
+
417
+ return clear_event;
418
+ };
419
+ }
420
+
421
+ /**
422
+ * @param {unknown} maybe_tracked
423
+ * @returns {(node: HTMLElement) => void}
424
+ */
425
+ export function bindInnerHTML(maybe_tracked) {
426
+ return bind_content_editable(maybe_tracked, 'innerHTML');
427
+ }
428
+
429
+ /**
430
+ * @param {unknown} maybe_tracked
431
+ * @returns {(node: HTMLElement) => void}
432
+ */
433
+ export function bindInnerText(maybe_tracked) {
434
+ return bind_content_editable(maybe_tracked, 'innerText');
435
+ }
436
+
437
+ /**
438
+ * @param {unknown} maybe_tracked
439
+ * @returns {(node: HTMLElement) => void}
440
+ */
441
+ export function bindTextContent(maybe_tracked) {
442
+ return bind_content_editable(maybe_tracked, 'textContent');
443
+ }
@@ -65,6 +65,10 @@ export { tracked_array } from '../../array.js';
65
65
 
66
66
  export { tracked_object } from '../../object.js';
67
67
 
68
+ export { tracked_map } from '../../map.js';
69
+
70
+ export { tracked_set } from '../../set.js';
71
+
68
72
  export { head } from './head.js';
69
73
 
70
74
  export { script } from './script.js';
@@ -1,5 +1,5 @@
1
1
  /** @import { Block, Tracked } from '#client' */
2
- import { get, increment, safe_scope, set, tracked } from './internal/client/runtime.js';
2
+ import { get, increment, safe_scope, set, tracked, with_scope } from './internal/client/runtime.js';
3
3
 
4
4
  const introspect_methods = ['entries', 'forEach', 'values', Symbol.iterator];
5
5
 
@@ -193,3 +193,13 @@ export class TrackedMap extends Map {
193
193
  return [...this];
194
194
  }
195
195
  }
196
+
197
+ /**
198
+ * @template K, V
199
+ * @param {Block} block
200
+ * @param {...any} args
201
+ * @returns {TrackedMap<K, V>}
202
+ */
203
+ export function tracked_map(block, ...args) {
204
+ return with_scope(block, () => new TrackedMap(...args));
205
+ }
@@ -1,5 +1,5 @@
1
1
  /** @import { Block, Tracked } from '#client' */
2
- import { get, increment, safe_scope, set, tracked } from './internal/client/runtime.js';
2
+ import { get, increment, safe_scope, set, tracked, with_scope } from './internal/client/runtime.js';
3
3
 
4
4
  const introspect_methods = ['entries', 'forEach', 'keys', 'values', Symbol.iterator];
5
5
 
@@ -193,3 +193,13 @@ export class TrackedSet extends Set {
193
193
  return [...this];
194
194
  }
195
195
  }
196
+
197
+ /**
198
+ * @template V
199
+ * @param {Block} block
200
+ * @param {...any} args
201
+ * @returns {TrackedSet<V>}
202
+ */
203
+ export function tracked_set(block, ...args) {
204
+ return with_scope(block, () => new TrackedSet(...args));
205
+ }
@@ -138,4 +138,85 @@ describe('TrackedMap', () => {
138
138
 
139
139
  expect(container.querySelectorAll('pre')[0].textContent).toBe('[["foo",1],["bar",2]]');
140
140
  });
141
+
142
+ it('creates empty TrackedMap using #Map() shorthand syntax', () => {
143
+ component MapTest() {
144
+ let map = #Map();
145
+
146
+ <button onClick={() => map.set('a', 1)}>{'add'}</button>
147
+ <pre>{map.size}</pre>
148
+ }
149
+
150
+ render(MapTest);
151
+
152
+ expect(container.querySelector('pre').textContent).toBe('0');
153
+
154
+ const addButton = container.querySelector('button');
155
+ addButton.click();
156
+ flushSync();
157
+
158
+ expect(container.querySelector('pre').textContent).toBe('1');
159
+ });
160
+
161
+
162
+ it('creates TrackedMap with initial entries using #Map() shorthand syntax', () => {
163
+ component MapTest() {
164
+ let map = #Map([['a', 1], ['b', 2], ['c', 3]]);
165
+ let value = track(() => map.get('b'));
166
+
167
+ <button onClick={() => map.set('b', 10)}>{'update'}</button>
168
+ <pre>{map.size}</pre>
169
+ <pre>{@value}</pre>
170
+ }
171
+
172
+ render(MapTest);
173
+
174
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('3');
175
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('2');
176
+
177
+ const updateButton = container.querySelector('button');
178
+ updateButton.click();
179
+ flushSync();
180
+
181
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('10');
182
+ });
183
+
184
+ it('handles all operations with #Map() shorthand syntax', () => {
185
+ component MapTest() {
186
+ let map = #Map([['x', 100], ['y', 200]]);
187
+ let keys = track(() => Array.from(map.keys()));
188
+
189
+ <button onClick={() => map.set('z', 300)}>{'add'}</button>
190
+ <button onClick={() => map.delete('x')}>{'delete'}</button>
191
+ <button onClick={() => map.clear()}>{'clear'}</button>
192
+
193
+ <pre>{JSON.stringify(@keys)}</pre>
194
+ <pre>{map.size}</pre>
195
+ }
196
+
197
+ render(MapTest);
198
+
199
+ const [addButton, deleteButton, clearButton] = container.querySelectorAll('button');
200
+
201
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('["x","y"]');
202
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('2');
203
+
204
+ addButton.click();
205
+ flushSync();
206
+
207
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('["x","y","z"]');
208
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('3');
209
+
210
+ deleteButton.click();
211
+ flushSync();
212
+
213
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('["y","z"]');
214
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('2');
215
+
216
+ clearButton.click();
217
+ flushSync();
218
+
219
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[]');
220
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('0');
221
+ });
141
222
  });
@@ -96,4 +96,85 @@ describe('TrackedSet', () => {
96
96
 
97
97
  expect(container.querySelectorAll('pre')[0].textContent).toBe('false');
98
98
  });
99
+
100
+ it('creates empty TrackedSet using #Set() shorthand syntax', () => {
101
+ component SetTest() {
102
+ let items = #Set();
103
+
104
+ <button onClick={() => items.add(1)}>{'add'}</button>
105
+ <pre>{items.size}</pre>
106
+ }
107
+
108
+ render(SetTest);
109
+
110
+ expect(container.querySelector('pre').textContent).toBe('0');
111
+
112
+ const addButton = container.querySelector('button');
113
+ addButton.click();
114
+ flushSync();
115
+
116
+ expect(container.querySelector('pre').textContent).toBe('1');
117
+ });
118
+
119
+ it('creates TrackedSet with initial values using #Set() shorthand syntax', () => {
120
+ component SetTest() {
121
+ let items = #Set([1, 2, 3, 4]);
122
+ let hasValue = track(() => items.has(3));
123
+
124
+ <button onClick={() => items.delete(3)}>{'delete'}</button>
125
+ <pre>{items.size}</pre>
126
+ <pre>{@hasValue}</pre>
127
+ }
128
+
129
+ render(SetTest);
130
+
131
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('4');
132
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('true');
133
+
134
+ const deleteButton = container.querySelector('button');
135
+ deleteButton.click();
136
+ flushSync();
137
+
138
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('3');
139
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('false');
140
+ });
141
+
142
+ it('handles all operations with #Set() shorthand syntax', () => {
143
+ component SetTest() {
144
+ let items = #Set([10, 20, 30]);
145
+ let values = track(() => Array.from(items.values()));
146
+
147
+ <button onClick={() => items.add(40)}>{'add'}</button>
148
+ <button onClick={() => items.delete(20)}>{'delete'}</button>
149
+ <button onClick={() => items.clear()}>{'clear'}</button>
150
+
151
+ <pre>{JSON.stringify(@values)}</pre>
152
+ <pre>{items.size}</pre>
153
+ }
154
+
155
+ render(SetTest);
156
+
157
+ const [addButton, deleteButton, clearButton] = container.querySelectorAll('button');
158
+
159
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[10,20,30]');
160
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('3');
161
+
162
+ addButton.click();
163
+ flushSync();
164
+
165
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[10,20,30,40]');
166
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('4');
167
+
168
+ deleteButton.click();
169
+ flushSync();
170
+
171
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[10,30,40]');
172
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('3');
173
+
174
+ clearButton.click();
175
+ flushSync();
176
+
177
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('[]');
178
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('0');
179
+ });
99
180
  });