native-document 1.0.92 → 1.0.94

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.
Files changed (85) hide show
  1. package/dist/native-document.components.min.js +1088 -65
  2. package/dist/native-document.dev.js +695 -142
  3. package/dist/native-document.dev.js.map +1 -1
  4. package/dist/native-document.devtools.min.js +1 -1
  5. package/dist/native-document.min.js +1 -1
  6. package/docs/advanced-components.md +814 -0
  7. package/docs/anchor.md +71 -11
  8. package/docs/cache.md +888 -0
  9. package/docs/conditional-rendering.md +91 -1
  10. package/docs/core-concepts.md +9 -2
  11. package/docs/elements.md +127 -2
  12. package/docs/extending-native-document-element.md +7 -1
  13. package/docs/filters.md +1216 -0
  14. package/docs/getting-started.md +12 -3
  15. package/docs/lifecycle-events.md +10 -2
  16. package/docs/list-rendering.md +453 -54
  17. package/docs/memory-management.md +9 -7
  18. package/docs/native-document-element.md +30 -9
  19. package/docs/native-fetch.md +744 -0
  20. package/docs/observables.md +135 -6
  21. package/docs/routing.md +7 -1
  22. package/docs/state-management.md +7 -1
  23. package/docs/validation.md +8 -1
  24. package/elements.js +1 -0
  25. package/eslint.config.js +3 -3
  26. package/index.def.js +350 -0
  27. package/package.json +3 -2
  28. package/readme.md +53 -14
  29. package/src/components/$traits/HasItems.js +42 -1
  30. package/src/components/BaseComponent.js +4 -1
  31. package/src/components/accordion/Accordion.js +112 -8
  32. package/src/components/accordion/AccordionItem.js +93 -4
  33. package/src/components/alert/Alert.js +164 -4
  34. package/src/components/avatar/Avatar.js +236 -22
  35. package/src/components/menu/index.js +1 -2
  36. package/src/core/data/ObservableArray.js +120 -2
  37. package/src/core/data/ObservableChecker.js +50 -0
  38. package/src/core/data/ObservableItem.js +124 -4
  39. package/src/core/data/ObservableWhen.js +36 -6
  40. package/src/core/data/observable-helpers/array.js +12 -3
  41. package/src/core/data/observable-helpers/computed.js +17 -4
  42. package/src/core/data/observable-helpers/object.js +19 -3
  43. package/src/core/elements/content-formatter.js +138 -1
  44. package/src/core/elements/control/for-each-array.js +20 -2
  45. package/src/core/elements/control/for-each.js +17 -5
  46. package/src/core/elements/control/show-if.js +31 -15
  47. package/src/core/elements/control/show-when.js +23 -0
  48. package/src/core/elements/control/switch.js +40 -10
  49. package/src/core/elements/description-list.js +14 -0
  50. package/src/core/elements/form.js +188 -4
  51. package/src/core/elements/html5-semantics.js +44 -1
  52. package/src/core/elements/img.js +22 -10
  53. package/src/core/elements/index.js +5 -0
  54. package/src/core/elements/interactive.js +19 -1
  55. package/src/core/elements/list.js +28 -1
  56. package/src/core/elements/medias.js +29 -0
  57. package/src/core/elements/meta-data.js +34 -0
  58. package/src/core/elements/table.js +59 -0
  59. package/src/core/utils/cache.js +5 -0
  60. package/src/core/utils/helpers.js +7 -2
  61. package/src/core/utils/memoize.js +25 -16
  62. package/src/core/utils/prototypes.js +3 -2
  63. package/src/core/wrappers/AttributesWrapper.js +1 -1
  64. package/src/core/wrappers/HtmlElementWrapper.js +2 -2
  65. package/src/core/wrappers/NDElement.js +42 -2
  66. package/src/core/wrappers/NdPrototype.js +4 -0
  67. package/src/core/wrappers/TemplateCloner.js +14 -11
  68. package/src/core/wrappers/prototypes/bind-class-extensions.js +1 -1
  69. package/src/core/wrappers/prototypes/nd-element-extensions.js +3 -0
  70. package/src/router/Route.js +9 -4
  71. package/src/router/Router.js +28 -9
  72. package/src/router/errors/RouterError.js +0 -1
  73. package/types/control-flow.d.ts +9 -6
  74. package/types/elements.d.ts +496 -111
  75. package/types/filters/index.d.ts +4 -0
  76. package/types/forms.d.ts +85 -48
  77. package/types/images.d.ts +16 -9
  78. package/types/nd-element.d.ts +5 -238
  79. package/types/observable.d.ts +9 -3
  80. package/types/router.d.ts +5 -1
  81. package/types/template-cloner.ts +1 -0
  82. package/types/validator.ts +11 -1
  83. package/utils.d.ts +2 -1
  84. package/utils.js +4 -4
  85. package/src/core/utils/service.js +0 -6
@@ -0,0 +1,1216 @@
1
+ # Filters
2
+
3
+ NativeDocument provides a comprehensive set of filter helpers for creating reactive, type-safe data filtering with Observable arrays. These filters work seamlessly with the `where()`, `whereSome()`, and `whereEvery()` methods.
4
+
5
+ ## Overview
6
+
7
+ Filter helpers enable:
8
+ - **Reactive filtering** - Filters update automatically when observables change
9
+ - **Type-safe comparisons** - Validate data types and formats
10
+ - **Composable logic** - Combine filters with `and`, `or`, `not`
11
+ - **Date/Time handling** - Specialized filters for temporal data
12
+ - **Custom filters** - Create your own filter logic
13
+
14
+ ## Import
15
+ ```javascript
16
+ import { filters } from 'native-document/utils';
17
+ const { equals, greaterThan, between, includes, and, or, not } = filters;
18
+
19
+ // Or destructure directly
20
+ import { filters: { equals, greaterThan, between, includes } } from 'native-document/utils';
21
+ ```
22
+
23
+ ## Basic Filters
24
+
25
+ ### Comparison Filters
26
+ ```javascript
27
+ import { filters } from 'native-document/utils';
28
+ const { equals, notEquals, greaterThan, lessThan } = filters;
29
+ import { Observable } from 'native-document';
30
+
31
+ const products = Observable.array([
32
+ { id: 1, name: 'Phone', price: 599, stock: 10 },
33
+ { id: 2, name: 'Laptop', price: 999, stock: 5 },
34
+ { id: 3, name: 'Tablet', price: 399, stock: 0 },
35
+ { id: 4, name: 'Watch', price: 299, stock: 15 }
36
+ ]);
37
+
38
+ // Filter by exact value
39
+ const expensive = products.where({
40
+ price: equals(999)
41
+ });
42
+ // Result: [{ id: 2, name: 'Laptop', price: 999, stock: 5 }]
43
+
44
+ // Filter by inequality
45
+ const notTablet = products.where({
46
+ name: notEquals('Tablet')
47
+ });
48
+ // Result: All except Tablet
49
+
50
+ // Greater than
51
+ const expensiveProducts = products.where({
52
+ price: greaterThan(500)
53
+ });
54
+ // Result: Phone and Laptop
55
+
56
+ // Less than
57
+ const affordable = products.where({
58
+ price: lessThan(400)
59
+ });
60
+ // Result: Watch and Tablet
61
+ ```
62
+
63
+ ### Shortcuts
64
+ ```javascript
65
+ import { filters } from 'native-document/utils';
66
+ const { eq, neq, gt, gte, lt, lte } = filters;
67
+
68
+ const products = Observable.array([...]);
69
+
70
+ // Short aliases
71
+ const expensive = products.where({ price: gt(500) });
72
+ const affordable = products.where({ price: lte(400) });
73
+ const notPhone = products.where({ name: neq('Phone') });
74
+ ```
75
+
76
+ ### greaterThanOrEqual() / lessThanOrEqual()
77
+ ```javascript
78
+ import { filters } from 'native-document/utils';
79
+ const { greaterThanOrEqual, lessThanOrEqual } = filters;
80
+ import { Observable } from 'native-document';
81
+
82
+ const products = Observable.array([
83
+ { name: 'Budget', price: 100 },
84
+ { name: 'Standard', price: 500 },
85
+ { name: 'Premium', price: 1000 }
86
+ ]);
87
+
88
+ // Greater than or equal to
89
+ const standardOrBetter = products.where({
90
+ price: greaterThanOrEqual(500)
91
+ });
92
+ // Result: Standard, Premium
93
+
94
+ // Less than or equal to
95
+ const budgetFriendly = products.where({
96
+ price: lessThanOrEqual(500)
97
+ });
98
+ // Result: Budget, Standard
99
+ ```
100
+
101
+ ## Range Filters
102
+
103
+ ### between()
104
+ ```javascript
105
+ import { filters } from 'native-document/utils';
106
+ const { between } = filters;
107
+ import { Observable } from 'native-document';
108
+
109
+ const products = Observable.array([
110
+ { name: 'Budget Phone', price: 199 },
111
+ { name: 'Mid Phone', price: 499 },
112
+ { name: 'Premium Phone', price: 999 },
113
+ { name: 'Luxury Phone', price: 1499 }
114
+ ]);
115
+
116
+ // Static range
117
+ const midRange = products.where({
118
+ price: between(400, 800)
119
+ });
120
+ // Result: [{ name: 'Mid Phone', price: 499 }]
121
+
122
+ // Reactive range with observables
123
+ const minPrice = Observable(200);
124
+ const maxPrice = Observable(1000);
125
+
126
+ const filtered = products.where({
127
+ price: between(minPrice, maxPrice)
128
+ });
129
+
130
+ // Updates automatically when bounds change
131
+ minPrice.set(500); // Now shows only Premium Phone
132
+ maxPrice.set(600); // Now shows nothing
133
+ ```
134
+
135
+ ## String Filters
136
+
137
+ ### includes() / contains()
138
+ ```javascript
139
+ import { filters } from 'native-document/utils';
140
+ const { includes, contains } = filters;
141
+ import { Observable } from 'native-document';
142
+
143
+ const products = Observable.array([
144
+ { name: 'iPhone 15 Pro' },
145
+ { name: 'Samsung Galaxy' },
146
+ { name: 'Google Pixel' },
147
+ { name: 'OnePlus Phone' }
148
+ ]);
149
+
150
+ // Case-insensitive by default
151
+ const phones = products.where({
152
+ name: includes('phone')
153
+ });
154
+ // Result: iPhone 15 Pro, OnePlus Phone
155
+
156
+ // Reactive search
157
+ const searchTerm = Observable('galaxy');
158
+ const results = products.where({
159
+ name: includes(searchTerm)
160
+ });
161
+ // Result: Samsung Galaxy
162
+
163
+ searchTerm.set('pixel');
164
+ // Result: Google Pixel
165
+
166
+ // contains is an alias
167
+ const sameResults = products.where({
168
+ name: contains('phone')
169
+ });
170
+ ```
171
+
172
+ ### startsWith()
173
+ ```javascript
174
+ import { filters } from 'native-document/utils';
175
+ const { startsWith } = filters;
176
+ import { Observable } from 'native-document';
177
+
178
+ const users = Observable.array([
179
+ { name: 'Alice Johnson' },
180
+ { name: 'Bob Smith' },
181
+ { name: 'Alice Brown' },
182
+ { name: 'Charlie Wilson' }
183
+ ]);
184
+
185
+ // Case-insensitive by default
186
+ const alices = users.where({
187
+ name: startsWith('alice')
188
+ });
189
+ // Result: Alice Johnson, Alice Brown
190
+
191
+ // Case-sensitive (second parameter)
192
+ const caseSensitive = users.where({
193
+ name: startsWith('Alice', true)
194
+ });
195
+ // Result: Alice Johnson, Alice Brown
196
+ ```
197
+
198
+ ### endsWith()
199
+ ```javascript
200
+ import { filters } from 'native-document/utils';
201
+ const { endsWith } = filters;
202
+ import { Observable } from 'native-document';
203
+
204
+ const files = Observable.array([
205
+ { name: 'document.pdf' },
206
+ { name: 'image.jpg' },
207
+ { name: 'report.pdf' },
208
+ { name: 'photo.png' }
209
+ ]);
210
+
211
+ // Case-insensitive by default
212
+ const pdfs = files.where({
213
+ name: endsWith('.pdf')
214
+ });
215
+ // Result: document.pdf, report.pdf
216
+
217
+ // Case-sensitive
218
+ const pdfsCaseSensitive = files.where({
219
+ name: endsWith('.PDF', true)
220
+ });
221
+ // Result: [] (none match uppercase)
222
+ ```
223
+
224
+ ### match()
225
+ ```javascript
226
+ import { filters } from 'native-document/utils';
227
+ const { match } = filters;
228
+ import { Observable } from 'native-document';
229
+
230
+ const products = Observable.array([
231
+ { sku: 'ABC-123' },
232
+ { sku: 'DEF-456' },
233
+ { sku: 'GHI-789' },
234
+ { sku: 'INVALID' }
235
+ ]);
236
+
237
+ // Regex pattern
238
+ const validSKUs = products.where({
239
+ sku: match(/^[A-Z]{3}-\d{3}$/, true)
240
+ });
241
+ // Result: ABC-123, DEF-456, GHI-789
242
+
243
+ // Simple text match (no regex)
244
+ const containsABC = products.where({
245
+ sku: match('ABC', false)
246
+ });
247
+ // Result: ABC-123
248
+
249
+ // With flags
250
+ const caseInsensitive = products.where({
251
+ sku: match(/abc/, true, 'i')
252
+ });
253
+ // Result: ABC-123
254
+ ```
255
+
256
+ ## Array Filters
257
+
258
+ ### inArray()
259
+ ```javascript
260
+ import { filters } from 'native-document/utils';
261
+ const { inArray } = filters;
262
+ import { Observable } from 'native-document';
263
+
264
+ const products = Observable.array([
265
+ { id: 1, category: 'electronics' },
266
+ { id: 2, category: 'books' },
267
+ { id: 3, category: 'clothing' },
268
+ { id: 4, category: 'electronics' }
269
+ ]);
270
+
271
+ // Static array
272
+ const allowed = products.where({
273
+ category: inArray(['electronics', 'books'])
274
+ });
275
+ // Result: items 1, 2, 4
276
+
277
+ // Reactive array
278
+ const allowedCategories = Observable.array(['electronics']);
279
+ const filtered = products.where({
280
+ category: inArray(allowedCategories)
281
+ });
282
+
283
+ // Updates when array changes
284
+ allowedCategories.push('books');
285
+ // Now includes books too
286
+ ```
287
+
288
+ ### notIn()
289
+ ```javascript
290
+ import { filters } from 'native-document/utils';
291
+ const { notIn } = filters;
292
+ import { Observable } from 'native-document';
293
+
294
+ const users = Observable.array([
295
+ { id: 1, status: 'active' },
296
+ { id: 2, status: 'banned' },
297
+ { id: 3, status: 'inactive' },
298
+ { id: 4, status: 'active' }
299
+ ]);
300
+
301
+ const validUsers = users.where({
302
+ status: notIn(['banned', 'deleted'])
303
+ });
304
+ // Result: items 1, 3, 4
305
+ ```
306
+
307
+ ## Empty/Existence Filters
308
+
309
+ ### isEmpty()
310
+ ```javascript
311
+ import { filters } from 'native-document/utils';
312
+ const { isEmpty } = filters;
313
+ import { Observable } from 'native-document';
314
+
315
+ const tasks = Observable.array([
316
+ { title: 'Task 1', description: '' },
317
+ { title: 'Task 2', description: 'Details' },
318
+ { title: 'Task 3', description: null },
319
+ { title: 'Task 4', tags: [] }
320
+ ]);
321
+
322
+ // Empty string or null
323
+ const noDescription = tasks.where({
324
+ description: isEmpty()
325
+ });
326
+ // Result: Task 1, Task 3
327
+
328
+ // Empty arrays
329
+ const noTags = tasks.where({
330
+ tags: isEmpty()
331
+ });
332
+ // Result: Task 4
333
+
334
+ // Conditional empty
335
+ const shouldBeEmpty = Observable(true);
336
+ const filtered = tasks.where({
337
+ description: isEmpty(shouldBeEmpty)
338
+ });
339
+
340
+ shouldBeEmpty.set(false);
341
+ // Now returns items with non-empty descriptions
342
+ ```
343
+
344
+ ### isNotEmpty()
345
+ ```javascript
346
+ import { filters } from 'native-document/utils';
347
+ const { isNotEmpty } = filters;
348
+ import { Observable } from 'native-document';
349
+
350
+ const tasks = Observable.array([
351
+ { title: 'Task 1', description: '' },
352
+ { title: 'Task 2', description: 'Details' },
353
+ { title: 'Task 3', description: null }
354
+ ]);
355
+
356
+ const withDescription = tasks.where({
357
+ description: isNotEmpty()
358
+ });
359
+ // Result: Task 2
360
+
361
+ // Conditional
362
+ const shouldHaveContent = Observable(true);
363
+ const filtered = tasks.where({
364
+ description: isNotEmpty(shouldHaveContent)
365
+ });
366
+ ```
367
+
368
+ ## Date and Time Filters
369
+
370
+ All date and time filters automatically convert values to Date objects using the internal `toDate()` helper. You can pass Date objects, timestamps, or date strings - they will be converted automatically.
371
+
372
+ ### Date Comparison
373
+ ```javascript
374
+ import { filters } from 'native-document/utils';
375
+ const { dateEquals, dateBefore, dateAfter, dateBetween } = filters;
376
+ import { Observable } from 'native-document';
377
+
378
+ const events = Observable.array([
379
+ { name: 'Meeting', date: '2024-01-15' },
380
+ { name: 'Conference', date: '2024-06-20' },
381
+ { name: 'Workshop', date: '2024-09-10' },
382
+ { name: 'Seminar', date: '2024-12-05' }
383
+ ]);
384
+
385
+ // Specific date (string automatically converted to Date)
386
+ const januaryEvents = events.where({
387
+ date: dateEquals('2024-01-15')
388
+ });
389
+ // Result: Meeting
390
+
391
+ // Or with Date object
392
+ const januaryEvents2 = events.where({
393
+ date: dateEquals(new Date('2024-01-15'))
394
+ });
395
+ // Result: Meeting
396
+
397
+ // Before a date
398
+ const firstHalf = events.where({
399
+ date: dateBefore('2024-07-01')
400
+ });
401
+ // Result: Meeting, Conference
402
+
403
+ // After a date
404
+ const secondHalf = events.where({
405
+ date: dateAfter('2024-07-01')
406
+ });
407
+ // Result: Workshop, Seminar
408
+
409
+ // Date range
410
+ const summerEvents = events.where({
411
+ date: dateBetween('2024-06-01', '2024-08-31')
412
+ });
413
+ // Result: Conference
414
+
415
+ // Reactive date filtering with observables
416
+ const startDate = Observable('2024-01-01');
417
+ const endDate = Observable('2024-06-30');
418
+
419
+ const filtered = events.where({
420
+ date: dateBetween(startDate, endDate)
421
+ });
422
+
423
+ // Updates when dates change
424
+ endDate.set('2024-12-31');
425
+ // Now includes all events
426
+
427
+ // Works with timestamps too
428
+ const timestamp = Date.now();
429
+ const recentEvents = events.where({
430
+ date: dateAfter(timestamp)
431
+ });
432
+ ```
433
+
434
+ ### Time Comparison (Ignores Date)
435
+
436
+ Time filters extract and compare only the time portion (hours, minutes, seconds), ignoring the date.
437
+ ```javascript
438
+ import { filters } from 'native-document/utils';
439
+ const { timeEquals, timeBefore, timeAfter, timeBetween } = filters;
440
+ import { Observable } from 'native-document';
441
+
442
+ const appointments = Observable.array([
443
+ { name: 'Breakfast', time: '2024-01-15 08:00:00' },
444
+ { name: 'Meeting', time: '2024-01-15 14:00:00' },
445
+ { name: 'Dinner', time: '2024-01-15 19:00:00' }
446
+ ]);
447
+
448
+ // Specific time (date is ignored, only time matters)
449
+ const lunchTime = appointments.where({
450
+ time: timeEquals('2024-01-01 14:00:00')
451
+ });
452
+ // Result: Meeting (date doesn't need to match)
453
+
454
+ // Before a time
455
+ const morning = appointments.where({
456
+ time: timeBefore('12:00:00')
457
+ });
458
+ // Result: Breakfast
459
+
460
+ // After a time
461
+ const evening = appointments.where({
462
+ time: timeAfter('18:00:00')
463
+ });
464
+ // Result: Dinner
465
+
466
+ // Time range (9 AM to 5 PM)
467
+ const businessHours = appointments.where({
468
+ time: timeBetween('09:00:00', '17:00:00')
469
+ });
470
+ // Result: Meeting
471
+
472
+ // Works with any date - only time is compared
473
+ const businessHours2 = appointments.where({
474
+ time: timeBetween('2025-12-25 09:00:00', '2025-12-25 17:00:00')
475
+ });
476
+ // Result: Meeting (same result, date is ignored)
477
+ ```
478
+
479
+ ### DateTime Comparison (Date + Time)
480
+
481
+ DateTime filters compare both date and time together for exact timestamp matching.
482
+ ```javascript
483
+ import { filters } from 'native-document/utils';
484
+ const { dateTimeEquals, dateTimeBefore, dateTimeAfter, dateTimeBetween } = filters;
485
+ import { Observable } from 'native-document';
486
+
487
+ const logs = Observable.array([
488
+ { message: 'Start', timestamp: '2024-01-15 08:30:00' },
489
+ { message: 'Process', timestamp: '2024-01-15 14:45:00' },
490
+ { message: 'End', timestamp: '2024-01-15 18:20:00' }
491
+ ]);
492
+
493
+ // Exact timestamp (both date and time must match)
494
+ const exactLog = logs.where({
495
+ timestamp: dateTimeEquals('2024-01-15 14:45:00')
496
+ });
497
+ // Result: Process
498
+
499
+ // Before timestamp
500
+ const earlyLogs = logs.where({
501
+ timestamp: dateTimeBefore('2024-01-15 15:00:00')
502
+ });
503
+ // Result: Start, Process
504
+
505
+ // After timestamp
506
+ const lateLogs = logs.where({
507
+ timestamp: dateTimeAfter('2024-01-15 15:00:00')
508
+ });
509
+ // Result: End
510
+
511
+ // Timestamp range (work hours: 9 AM to 5 PM)
512
+ const workHours = logs.where({
513
+ timestamp: dateTimeBetween(
514
+ '2024-01-15 09:00:00',
515
+ '2024-01-15 17:00:00'
516
+ )
517
+ });
518
+ // Result: Process
519
+
520
+ // Reactive datetime filtering
521
+ const startTime = Observable('2024-01-15 08:00:00');
522
+ const endTime = Observable('2024-01-15 16:00:00');
523
+
524
+ const filtered = logs.where({
525
+ timestamp: dateTimeBetween(startTime, endTime)
526
+ });
527
+
528
+ // Updates when times change
529
+ endTime.set('2024-01-15 20:00:00');
530
+ // Now includes all logs
531
+ ```
532
+
533
+ ### Date Format Examples
534
+
535
+ All date/time filters accept multiple formats:
536
+ ```javascript
537
+ import { filters } from 'native-document/utils';
538
+ const { dateEquals, timeEquals, dateTimeEquals } = filters;
539
+
540
+ const events = Observable.array([...]);
541
+
542
+ // ISO 8601 string
543
+ events.where({ date: dateEquals('2024-01-15') });
544
+
545
+ // Date object
546
+ events.where({ date: dateEquals(new Date('2024-01-15')) });
547
+
548
+ // Timestamp (milliseconds)
549
+ events.where({ date: dateEquals(1705276800000) });
550
+
551
+ // Full datetime string
552
+ events.where({ timestamp: dateTimeEquals('2024-01-15T14:30:00') });
553
+
554
+ // Time only (date portion ignored)
555
+ events.where({ time: timeEquals('14:30:00') });
556
+
557
+ // Observable with any format
558
+ const targetDate = Observable('2024-01-15');
559
+ events.where({ date: dateEquals(targetDate) });
560
+ ```
561
+
562
+ ### Working with Different Timezones
563
+ ```javascript
564
+ import { filters } from 'native-document/utils';
565
+ const { dateTimeBetween } = filters;
566
+ import { Observable } from 'native-document';
567
+
568
+ const events = Observable.array([
569
+ { name: 'Meeting', time: '2024-01-15T14:00:00Z' }, // UTC
570
+ { name: 'Call', time: '2024-01-15T09:00:00-05:00' }, // EST
571
+ { name: 'Workshop', time: '2024-01-15T16:00:00+01:00' } // CET
572
+ ]);
573
+
574
+ // All dates are converted to Date objects internally
575
+ // Local timezone is used for comparison
576
+ const todayEvents = events.where({
577
+ time: dateTimeBetween(
578
+ '2024-01-15T00:00:00',
579
+ '2024-01-15T23:59:59'
580
+ )
581
+ });
582
+ ```
583
+
584
+ ### Practical Date/Time Examples
585
+
586
+ #### Filter Events by Today/This Week
587
+ ```javascript
588
+ import { filters } from 'native-document/utils';
589
+ const { dateEquals, dateBetween } = filters;
590
+ import { Observable } from 'native-document';
591
+
592
+ const events = Observable.array([...]);
593
+
594
+ // Today's events
595
+ const today = new Date().toISOString().split('T')[0]; // "2024-01-15"
596
+ const todayEvents = events.where({
597
+ date: dateEquals(today)
598
+ });
599
+
600
+ // This week's events
601
+ const startOfWeek = new Date();
602
+ startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
603
+
604
+ const endOfWeek = new Date(startOfWeek);
605
+ endOfWeek.setDate(endOfWeek.getDate() + 6);
606
+
607
+ const thisWeekEvents = events.where({
608
+ date: dateBetween(
609
+ startOfWeek.toISOString().split('T')[0],
610
+ endOfWeek.toISOString().split('T')[0]
611
+ )
612
+ });
613
+ ```
614
+
615
+ #### Filter by Business Hours
616
+ ```javascript
617
+ import { filters } from 'native-document/utils';
618
+ const { timeBetween } = filters;
619
+ import { Observable } from 'native-document';
620
+
621
+ const calls = Observable.array([
622
+ { caller: 'Alice', time: '2024-01-15 08:30:00' },
623
+ { caller: 'Bob', time: '2024-01-15 14:30:00' },
624
+ { caller: 'Charlie', time: '2024-01-15 20:00:00' }
625
+ ]);
626
+
627
+ // Business hours: 9 AM - 6 PM
628
+ const businessHoursCalls = calls.where({
629
+ time: timeBetween('09:00:00', '18:00:00')
630
+ });
631
+ // Result: Bob (14:30 is within business hours)
632
+ ```
633
+
634
+ #### Filter Recent Activity
635
+ ```javascript
636
+ import { filters } from 'native-document/utils';
637
+ const { dateTimeAfter } = filters;
638
+ import { Observable } from 'native-document';
639
+
640
+ const activities = Observable.array([...]);
641
+
642
+ // Last 24 hours
643
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
644
+ const recentActivity = activities.where({
645
+ timestamp: dateTimeAfter(oneDayAgo)
646
+ });
647
+
648
+ // Last hour
649
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
650
+ const veryRecentActivity = activities.where({
651
+ timestamp: dateTimeAfter(oneHourAgo)
652
+ });
653
+ ```
654
+
655
+ ### Time Comparison (Ignores Date)
656
+ ```javascript
657
+ import { filters } from 'native-document/utils';
658
+ const { timeEquals, timeBefore, timeAfter, timeBetween } = filters;
659
+ import { Observable } from 'native-document';
660
+
661
+ const appointments = Observable.array([
662
+ { name: 'Breakfast', time: new Date('2024-01-15 08:00:00') },
663
+ { name: 'Meeting', time: new Date('2024-01-15 14:00:00') },
664
+ { name: 'Dinner', time: new Date('2024-01-15 19:00:00') }
665
+ ]);
666
+
667
+ // Specific time (ignores date)
668
+ const lunchTime = appointments.where({
669
+ time: timeEquals(new Date('2024-01-01 14:00:00'))
670
+ });
671
+ // Result: Meeting (even though date is different)
672
+
673
+ // Before a time
674
+ const morning = appointments.where({
675
+ time: timeBefore(new Date('2024-01-01 12:00:00'))
676
+ });
677
+ // Result: Breakfast
678
+
679
+ // After a time
680
+ const evening = appointments.where({
681
+ time: timeAfter(new Date('2024-01-01 18:00:00'))
682
+ });
683
+ // Result: Dinner
684
+
685
+ // Time range
686
+ const businessHours = appointments.where({
687
+ time: timeBetween(
688
+ new Date('2024-01-01 09:00:00'),
689
+ new Date('2024-01-01 17:00:00')
690
+ )
691
+ });
692
+ // Result: Meeting
693
+ ```
694
+
695
+ ### DateTime Comparison (Date + Time)
696
+ ```javascript
697
+ import { filters } from 'native-document/utils';
698
+ const { dateTimeEquals, dateTimeBefore, dateTimeAfter, dateTimeBetween } = filters;
699
+ import { Observable } from 'native-document';
700
+
701
+ const logs = Observable.array([
702
+ { message: 'Start', timestamp: new Date('2024-01-15 08:30:00') },
703
+ { message: 'Process', timestamp: new Date('2024-01-15 14:45:00') },
704
+ { message: 'End', timestamp: new Date('2024-01-15 18:20:00') }
705
+ ]);
706
+
707
+ // Exact timestamp
708
+ const exactLog = logs.where({
709
+ timestamp: dateTimeEquals(new Date('2024-01-15 14:45:00'))
710
+ });
711
+ // Result: Process
712
+
713
+ // Before timestamp
714
+ const earlyLogs = logs.where({
715
+ timestamp: dateTimeBefore(new Date('2024-01-15 15:00:00'))
716
+ });
717
+ // Result: Start, Process
718
+
719
+ // After timestamp
720
+ const lateLogs = logs.where({
721
+ timestamp: dateTimeAfter(new Date('2024-01-15 15:00:00'))
722
+ });
723
+ // Result: End
724
+
725
+ // Timestamp range
726
+ const workHours = logs.where({
727
+ timestamp: dateTimeBetween(
728
+ new Date('2024-01-15 09:00:00'),
729
+ new Date('2024-01-15 17:00:00')
730
+ )
731
+ });
732
+ // Result: Process
733
+ ```
734
+
735
+ ## Logical Operators
736
+
737
+ ### and() / all()
738
+ ```javascript
739
+ import { filters } from 'native-document/utils';
740
+ const { and, all, greaterThan, lessThan } = filters;
741
+ import { Observable } from 'native-document';
742
+
743
+ const products = Observable.array([
744
+ { name: 'Phone', price: 599, stock: 10 },
745
+ { name: 'Laptop', price: 999, stock: 5 },
746
+ { name: 'Tablet', price: 399, stock: 0 },
747
+ { name: 'Watch', price: 299, stock: 15 }
748
+ ]);
749
+
750
+ // Combine multiple conditions
751
+ const midRangeInStock = products.where({
752
+ price: and(
753
+ greaterThan(300),
754
+ lessThan(700)
755
+ ),
756
+ stock: greaterThan(0)
757
+ });
758
+ // Result: Phone
759
+
760
+ // 'all' is an alias for 'and'
761
+ const sameResult = products.where({
762
+ price: all(
763
+ greaterThan(300),
764
+ lessThan(700)
765
+ )
766
+ });
767
+ ```
768
+
769
+ ### or() / any()
770
+ ```javascript
771
+ import { filters } from 'native-document/utils';
772
+ const { or, any, lessThan, greaterThan } = filters;
773
+ import { Observable } from 'native-document';
774
+
775
+ const products = Observable.array([
776
+ { name: 'Budget Phone', price: 199 },
777
+ { name: 'Mid Phone', price: 499 },
778
+ { name: 'Premium Phone', price: 999 }
779
+ ]);
780
+
781
+ // Either cheap OR expensive
782
+ const dealsOrPremium = products.where({
783
+ price: or(
784
+ lessThan(300),
785
+ greaterThan(800)
786
+ )
787
+ });
788
+ // Result: Budget Phone, Premium Phone
789
+
790
+ // 'any' is an alias for 'or'
791
+ const sameResult = products.where({
792
+ price: any(
793
+ lessThan(300),
794
+ greaterThan(800)
795
+ )
796
+ });
797
+ ```
798
+
799
+ ### not()
800
+ ```javascript
801
+ import { filters } from 'native-document/utils';
802
+ const { not, equals } = filters;
803
+ import { Observable } from 'native-document';
804
+
805
+ const users = Observable.array([
806
+ { name: 'Alice', status: 'active' },
807
+ { name: 'Bob', status: 'inactive' },
808
+ { name: 'Charlie', status: 'active' }
809
+ ]);
810
+
811
+ // Invert condition
812
+ const notActive = users.where({
813
+ status: not(equals('active'))
814
+ });
815
+ // Result: Bob
816
+
817
+ // Can combine with other filters
818
+ const notActiveOrBanned = users.where({
819
+ status: not(inArray(['active', 'banned']))
820
+ });
821
+ ```
822
+
823
+ ## Complex Filtering
824
+
825
+ ### Nested Conditions
826
+ ```javascript
827
+ import { filters } from 'native-document/utils';
828
+ const { and, or, greaterThan, lessThan, equals } = filters;
829
+ import { Observable } from 'native-document';
830
+
831
+ const products = Observable.array([
832
+ { name: 'Phone', price: 599, category: 'electronics', stock: 10 },
833
+ { name: 'Book', price: 29, category: 'books', stock: 50 },
834
+ { name: 'Laptop', price: 999, category: 'electronics', stock: 5 },
835
+ { name: 'Magazine', price: 9, category: 'books', stock: 100 }
836
+ ]);
837
+
838
+ // (electronics AND expensive) OR (books AND cheap)
839
+ const filtered = products.where({
840
+ _: or(
841
+ and(
842
+ (item) => item.category === 'electronics',
843
+ (item) => item.price > 500
844
+ ),
845
+ and(
846
+ (item) => item.category === 'books',
847
+ (item) => item.price < 20
848
+ )
849
+ )
850
+ });
851
+ // Result: Phone, Laptop, Magazine
852
+ ```
853
+
854
+ ### Multiple Property Filters
855
+ ```javascript
856
+ import { filters } from 'native-document/utils';
857
+ const { greaterThan, includes, equals } = filters;
858
+ import { Observable } from 'native-document';
859
+
860
+ const products = Observable.array([
861
+ { name: 'Gaming Phone', price: 799, category: 'electronics', tags: ['gaming', 'mobile'] },
862
+ { name: 'Office Laptop', price: 1299, category: 'electronics', tags: ['work', 'productivity'] },
863
+ { name: 'Budget Tablet', price: 299, category: 'electronics', tags: ['entertainment'] }
864
+ ]);
865
+
866
+ // Filter on multiple properties
867
+ const filtered = products.where({
868
+ price: greaterThan(500),
869
+ category: equals('electronics'),
870
+ name: includes('gaming')
871
+ });
872
+ // Result: Gaming Phone
873
+ ```
874
+
875
+ ### Reactive Multi-Condition Filters
876
+ ```javascript
877
+ import { filters } from 'native-document/utils';
878
+ const { and, greaterThan, lessThan, includes, custom } = filters;
879
+ import { Observable } from 'native-document';
880
+
881
+ const products = Observable.array([...]);
882
+
883
+ // Reactive filter values
884
+ const searchTerm = Observable('');
885
+ const minPrice = Observable(0);
886
+ const maxPrice = Observable(10000);
887
+ const showInStockOnly = Observable(false);
888
+
889
+ const filtered = products.where({
890
+ name: includes(searchTerm),
891
+ price: and(
892
+ greaterThan(minPrice),
893
+ lessThan(maxPrice)
894
+ ),
895
+ stock: custom((value, showInStock) => {
896
+ return !showInStock || value > 0;
897
+ }, showInStockOnly)
898
+ });
899
+
900
+ // Updates automatically when any filter changes
901
+ searchTerm.set('phone');
902
+ minPrice.set(500);
903
+ maxPrice.set(1000);
904
+ showInStockOnly.set(true);
905
+ ```
906
+
907
+ ## Custom Filters
908
+
909
+ ### createFilter()
910
+ ```javascript
911
+ import { filters } from 'native-document/utils';
912
+ const { createFilter } = filters;
913
+ import { Observable } from 'native-document';
914
+
915
+ // Create email validator
916
+ const isValidEmail = createFilter(
917
+ true, // static value or observable
918
+ (value, shouldBeValid) => {
919
+ const isValid = /\S+@\S+\.\S+/.test(value);
920
+ return shouldBeValid ? isValid : !isValid;
921
+ }
922
+ );
923
+
924
+ const users = Observable.array([
925
+ { email: 'alice@example.com' },
926
+ { email: 'invalid-email' },
927
+ { email: 'bob@example.com' }
928
+ ]);
929
+
930
+ const validUsers = users.where({
931
+ email: isValidEmail
932
+ });
933
+ // Result: alice@example.com, bob@example.com
934
+
935
+ // Reactive validation
936
+ const shouldValidate = Observable(true);
937
+ const emailFilter = createFilter(
938
+ shouldValidate,
939
+ (value, validate) => {
940
+ if (!validate) return true; // Skip validation
941
+ return /\S+@\S+\.\S+/.test(value);
942
+ }
943
+ );
944
+
945
+ const filtered = users.where({
946
+ email: emailFilter
947
+ });
948
+
949
+ shouldValidate.set(false);
950
+ // Now returns all users (validation disabled)
951
+ ```
952
+
953
+ ### createMultiSourceFilter()
954
+ ```javascript
955
+ import { filters } from 'native-document/utils';
956
+ const { createMultiSourceFilter } = filters;
957
+ import { Observable } from 'native-document';
958
+
959
+ const minValue = Observable(0);
960
+ const maxValue = Observable(100);
961
+ const multiplier = Observable(1);
962
+
963
+ // Filter using multiple observables
964
+ const complexFilter = createMultiSourceFilter(
965
+ [minValue, maxValue, multiplier],
966
+ (value, [min, max, mult]) => {
967
+ const adjusted = value * mult;
968
+ return adjusted >= min && adjusted <= max;
969
+ }
970
+ );
971
+
972
+ const numbers = Observable.array([
973
+ { value: 10 },
974
+ { value: 50 },
975
+ { value: 150 }
976
+ ]);
977
+
978
+ const filtered = numbers.where({
979
+ value: complexFilter
980
+ });
981
+
982
+ // All observables update the filter
983
+ multiplier.set(2); // Now filters based on value * 2
984
+ minValue.set(50); // Now requires value * 2 >= 50
985
+ ```
986
+
987
+ ### custom()
988
+ ```javascript
989
+ import { filters } from 'native-document/utils';
990
+ const { custom } = filters;
991
+ import { Observable } from 'native-document';
992
+
993
+ const products = Observable.array([
994
+ { name: 'Phone', price: 599, discount: 0.1 },
995
+ { name: 'Laptop', price: 999, discount: 0.15 },
996
+ { name: 'Tablet', price: 399, discount: 0.05 }
997
+ ]);
998
+
999
+ const maxBudget = Observable(600);
1000
+
1001
+ // Custom filter with observable dependency
1002
+ const withinBudget = products.where({
1003
+ _: custom((product, budget) => {
1004
+ const finalPrice = product.price * (1 - product.discount);
1005
+ return finalPrice <= budget;
1006
+ }, maxBudget)
1007
+ });
1008
+ // Result: Phone, Tablet
1009
+
1010
+ // Updates when budget changes
1011
+ maxBudget.set(400);
1012
+ // Result: Tablet only
1013
+ ```
1014
+
1015
+ ## Filter Reference
1016
+
1017
+ ### Comparison Filters
1018
+
1019
+ | Filter | Alias | Description | Example |
1020
+ |--------|-------|-------------|---------|
1021
+ | `equals(value)` | `eq(value)` | Exact match | `equals(10)` |
1022
+ | `notEquals(value)` | `neq(value)` | Not equal | `notEquals('test')` |
1023
+ | `greaterThan(value)` | `gt(value)` | Greater than | `gt(100)` |
1024
+ | `greaterThanOrEqual(value)` | `gte(value)` | Greater or equal | `gte(50)` |
1025
+ | `lessThan(value)` | `lt(value)` | Less than | `lt(1000)` |
1026
+ | `lessThanOrEqual(value)` | `lte(value)` | Less or equal | `lte(500)` |
1027
+
1028
+ ### Range Filters
1029
+
1030
+ | Filter | Description | Example |
1031
+ |--------|-------------|---------|
1032
+ | `between(min, max)` | Value within range (inclusive) | `between(10, 100)` |
1033
+
1034
+ ### String Filters
1035
+
1036
+ | Filter | Description | Example |
1037
+ |--------|-------------|---------|
1038
+ | `includes(text, caseSensitive?)` | Contains substring | `includes('hello')` |
1039
+ | `contains(text, caseSensitive?)` | Alias for includes | `contains('world')` |
1040
+ | `startsWith(text, caseSensitive?)` | Starts with prefix | `startsWith('Mr')` |
1041
+ | `endsWith(text, caseSensitive?)` | Ends with suffix | `endsWith('.pdf')` |
1042
+ | `match(pattern, asRegex?, flags?)` | Pattern matching | `match(/\d+/, true)` |
1043
+
1044
+ ### Array Filters
1045
+
1046
+ | Filter | Description | Example |
1047
+ |--------|-------------|---------|
1048
+ | `inArray(array)` | Value in array | `inArray(['a', 'b'])` |
1049
+ | `notIn(array)` | Value not in array | `notIn(['banned'])` |
1050
+
1051
+ ### Empty Filters
1052
+
1053
+ | Filter | Description | Example |
1054
+ |--------|-------------|---------|
1055
+ | `isEmpty(shouldBeEmpty?)` | Value is empty/null | `isEmpty()` |
1056
+ | `isNotEmpty(shouldBeNotEmpty?)` | Value is not empty | `isNotEmpty()` |
1057
+
1058
+ ### Date Filters
1059
+
1060
+ | Filter | Description | Example |
1061
+ |--------|-------------|---------|
1062
+ | `dateEquals(date)` | Same date (ignores time) | `dateEquals(new Date())` |
1063
+ | `dateBefore(date)` | Before date | `dateBefore(new Date())` |
1064
+ | `dateAfter(date)` | After date | `dateAfter(new Date())` |
1065
+ | `dateBetween(start, end)` | Date range | `dateBetween(start, end)` |
1066
+
1067
+ ### Time Filters
1068
+
1069
+ | Filter | Description | Example |
1070
+ |--------|-------------|---------|
1071
+ | `timeEquals(time)` | Same time (ignores date) | `timeEquals(new Date())` |
1072
+ | `timeBefore(time)` | Before time | `timeBefore(new Date())` |
1073
+ | `timeAfter(time)` | After time | `timeAfter(new Date())` |
1074
+ | `timeBetween(start, end)` | Time range | `timeBetween(start, end)` |
1075
+
1076
+ ### DateTime Filters
1077
+
1078
+ | Filter | Description | Example |
1079
+ |--------|-------------|---------|
1080
+ | `dateTimeEquals(datetime)` | Exact timestamp | `dateTimeEquals(new Date())` |
1081
+ | `dateTimeBefore(datetime)` | Before timestamp | `dateTimeBefore(new Date())` |
1082
+ | `dateTimeAfter(datetime)` | After timestamp | `dateTimeAfter(new Date())` |
1083
+ | `dateTimeBetween(start, end)` | Timestamp range | `dateTimeBetween(start, end)` |
1084
+
1085
+ ### Logical Operators
1086
+
1087
+ | Filter | Alias | Description | Example |
1088
+ |--------|-------|-------------|---------|
1089
+ | `and(...filters)` | `all(...filters)` | All conditions must match | `and(gt(10), lt(100))` |
1090
+ | `or(...filters)` | `any(...filters)` | Any condition must match | `or(eq('a'), eq('b'))` |
1091
+ | `not(filter)` | - | Invert condition | `not(equals('test'))` |
1092
+
1093
+ ### Custom Filters
1094
+
1095
+ | Filter | Description | Example |
1096
+ |--------|-------------|---------|
1097
+ | `createFilter(value, callback)` | Single source custom filter | See above |
1098
+ | `createMultiSourceFilter(sources, callback)` | Multi-source custom filter | See above |
1099
+ | `custom(callback, ...observables)` | Custom logic with dependencies | See above |
1100
+
1101
+ ## Best Practices
1102
+
1103
+ ### 1. Use Specific Filters
1104
+ ```javascript
1105
+ import { filters } from 'native-document/utils';
1106
+ const { equals, greaterThan } = filters;
1107
+
1108
+ // ✅ Good: Specific property filters
1109
+ const filtered = products.where({
1110
+ price: greaterThan(100),
1111
+ category: equals('electronics')
1112
+ });
1113
+
1114
+ // ❌ Less efficient: Generic filter
1115
+ const filtered = products.where({
1116
+ _: (product) => product.price > 100 && product.category === 'electronics'
1117
+ });
1118
+ ```
1119
+
1120
+ ### 2. Reuse Observable Filters
1121
+ ```javascript
1122
+ import { filters } from 'native-document/utils';
1123
+ const { between } = filters;
1124
+ import { Observable } from 'native-document';
1125
+
1126
+ // ✅ Good: Reuse observables
1127
+ const minPrice = Observable(0);
1128
+ const maxPrice = Observable(1000);
1129
+
1130
+ const products1Filtered = products1.where({ price: between(minPrice, maxPrice) });
1131
+ const products2Filtered = products2.where({ price: between(minPrice, maxPrice) });
1132
+
1133
+ // Both update when observables change
1134
+ minPrice.set(500);
1135
+ ```
1136
+
1137
+ ### 3. Combine Related Filters
1138
+ ```javascript
1139
+ import { filters } from 'native-document/utils';
1140
+ const { and, greaterThan, lessThan } = filters;
1141
+
1142
+ // ✅ Good: Use 'and' for multiple conditions
1143
+ const filtered = products.where({
1144
+ price: and(greaterThan(100), lessThan(500))
1145
+ });
1146
+
1147
+ // ❌ Bad: Multiple where() calls
1148
+ const filtered = products
1149
+ .where({ price: greaterThan(100) })
1150
+ .where({ price: lessThan(500) });
1151
+ ```
1152
+
1153
+ ### 4. Document Complex Filters
1154
+ ```javascript
1155
+ import { filters } from 'native-document/utils';
1156
+ const { and, or, greaterThan, equals } = filters;
1157
+
1158
+ /**
1159
+ * Filters products for flash sale eligibility:
1160
+ * - In stock OR coming soon
1161
+ * - Price between $50-$500
1162
+ * - High rating (4+ stars)
1163
+ */
1164
+ const flashSaleProducts = products.where({
1165
+ stock: or(greaterThan(0), equals('coming-soon')),
1166
+ price: and(greaterThan(50), lessThan(500)),
1167
+ rating: greaterThan(4)
1168
+ });
1169
+ ```
1170
+
1171
+ ### 5. Avoid Over-Filtering
1172
+ ```javascript
1173
+ import { filters } from 'native-document/utils';
1174
+ const { equals, greaterThan } = filters;
1175
+
1176
+ // ❌ Bad: Too many where() calls
1177
+ const filtered = products
1178
+ .where({ category: equals('electronics') })
1179
+ .where({ price: greaterThan(100) })
1180
+ .where({ stock: greaterThan(0) })
1181
+ .where({ rating: greaterThan(4) });
1182
+
1183
+ // ✅ Good: Combine into single where
1184
+ const filtered = products.where({
1185
+ category: equals('electronics'),
1186
+ price: greaterThan(100),
1187
+ stock: greaterThan(0),
1188
+ rating: greaterThan(4)
1189
+ });
1190
+ ```
1191
+
1192
+ ## Next Steps
1193
+
1194
+ Explore related utilities and concepts:
1195
+
1196
+ ## Next Steps
1197
+
1198
+ - **[Getting Started](getting-started.md)** - Installation and first steps
1199
+ - **[Core Concepts](core-concepts.md)** - Understanding the fundamentals
1200
+ - **[Observables](observables.md)** - Reactive state management
1201
+ - **[Elements](elements.md)** - Creating and composing UI
1202
+ - **[Conditional Rendering](conditional-rendering.md)** - Dynamic content
1203
+ - **[List Rendering](list-rendering.md)** - (ForEach | ForEachArray) and dynamic lists
1204
+ - **[Routing](routing.md)** - Navigation and URL management
1205
+ - **[State Management](state-management.md)** - Global state patterns
1206
+ - **[NDElement](native-document-element.md)** - Native Document Element
1207
+ - **[Extending NDElement](extending-native-document-element.md)** - Custom Methods Guide
1208
+ - **[Advanced Components](advanced-components.md)** - Template caching and singleton views
1209
+ - **[Args Validation](validation.md)** - Function Argument Validation
1210
+ - **[Memory Management](memory-management.md)** - Memory management
1211
+
1212
+ ## Utilities
1213
+
1214
+ - **[Cache](docs/utils/cache.md)** - Lazy initialization and singleton patterns
1215
+ - **[NativeFetch](docs/utils/native-fetch.md)** - HTTP client with interceptors
1216
+ - **[Filters](docs/utils/filters.md)** - Data filtering helpers