advanced-filter-system 1.0.8 → 1.1.0

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/dist/AFS.esm.js DELETED
@@ -1,4809 +0,0 @@
1
- /**
2
- * @fileoverview Logging system for AFS
3
- */
4
-
5
- class Logger {
6
- /**
7
- * @param {boolean} [debug=false] - Enable debug mode
8
- * @param {string} [logLevel='info'] - Logging level
9
- */
10
- constructor() {
11
- let debug = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
12
- let logLevel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'info';
13
- this.enabled = debug;
14
- this.level = logLevel;
15
- this.levels = {
16
- error: 0,
17
- warn: 1,
18
- info: 2,
19
- debug: 3
20
- };
21
- }
22
-
23
- /**
24
- * Internal log method
25
- * @private
26
- */
27
- _log(level) {
28
- if (!this.enabled) return;
29
- const currentLevelValue = this.levels[this.level];
30
- const messageLevel = this.levels[level];
31
- if (messageLevel <= currentLevelValue) {
32
- const timestamp = new Date().toISOString();
33
- const prefix = `[AFS ${level.toUpperCase()}] ${timestamp}`;
34
- for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
35
- args[_key - 1] = arguments[_key];
36
- }
37
- switch (level) {
38
- case 'error':
39
- console.error(prefix, ...args);
40
- break;
41
- case 'warn':
42
- console.warn(prefix, ...args);
43
- break;
44
- case 'info':
45
- console.info(prefix, ...args);
46
- break;
47
- case 'debug':
48
- console.debug(prefix, ...args);
49
- break;
50
- }
51
- }
52
- }
53
-
54
- /**
55
- * Log error message
56
- * @public
57
- */
58
- error() {
59
- for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
60
- args[_key2] = arguments[_key2];
61
- }
62
- this._log('error', ...args);
63
- }
64
-
65
- /**
66
- * Log warning message
67
- * @public
68
- */
69
- warn() {
70
- for (var _len3 = arguments.length, args = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {
71
- args[_key3] = arguments[_key3];
72
- }
73
- this._log('warn', ...args);
74
- }
75
-
76
- /**
77
- * Log info message
78
- * @public
79
- */
80
- info() {
81
- for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) {
82
- args[_key4] = arguments[_key4];
83
- }
84
- this._log('info', ...args);
85
- }
86
-
87
- /**
88
- * Log debug message
89
- * @public
90
- */
91
- debug() {
92
- for (var _len5 = arguments.length, args = new Array(_len5), _key5 = 0; _key5 < _len5; _key5++) {
93
- args[_key5] = arguments[_key5];
94
- }
95
- this._log('debug', ...args);
96
- }
97
-
98
- /**
99
- * Enable or disable debug mode
100
- * @public
101
- */
102
- setDebugMode(enabled) {
103
- let level = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'info';
104
- this.enabled = enabled;
105
- if (this.levels.hasOwnProperty(level)) {
106
- this.level = level;
107
- }
108
- this._log('info', `Debug mode ${enabled ? 'enabled' : 'disabled'} with level: ${level}`);
109
- }
110
- }
111
-
112
- /**
113
- * @fileoverview Configuration management for AFS
114
- */
115
-
116
- class Options {
117
- /**
118
- * @typedef {Object} AFSOptions
119
- * @property {string} containerSelector - Main container selector
120
- * @property {string} itemSelector - Items to filter selector
121
- * @property {string} filterButtonSelector - Filter buttons selector
122
- * @property {string} searchInputSelector - Search input selector
123
- * @property {string} counterSelector - Results counter selector
124
- * @property {string} activeClass - Active state class
125
- * @property {string} hiddenClass - Hidden state class
126
- * @property {number} animationDuration - Animation duration in ms
127
- * @property {string} filterMode - Filter mode ('OR' or 'AND')
128
- * @property {string[]} searchKeys - Data attributes to search in
129
- * @property {number} debounceTime - Search debounce delay in ms
130
- * @property {boolean} debug - Enable debug mode
131
- * @property {string} logLevel - Log level
132
- * @property {string} dateFormat - Date format
133
- * @property {Object} counter - Counter-related options
134
- * @property {Object} styles - Style-related options
135
- */
136
-
137
- /**
138
- * @type {AFSOptions}
139
- */
140
- static defaults = {
141
- // Selectors
142
- containerSelector: '.afs-filter-container',
143
- itemSelector: '.afs-filter-item',
144
- filterButtonSelector: '.afs-btn-filter',
145
- searchInputSelector: '.afs-filter-search',
146
- counterSelector: '.afs-filter-counter',
147
- // Classes
148
- activeClass: 'active',
149
- hiddenClass: 'hidden',
150
- // Animation
151
- animationDuration: 300,
152
- animationType: 'fade',
153
- animationEasing: 'ease-out',
154
- // Filtering
155
- filterMode: 'OR',
156
- searchKeys: ['title'],
157
- debounceTime: 300,
158
- // Debug
159
- debug: false,
160
- logLevel: 'info',
161
- // Date handling
162
- dateFormat: 'YYYY-MM-DD',
163
- counter: {
164
- template: 'Showing {visible} of {total}',
165
- showFiltered: true,
166
- filteredTemplate: '({filtered} filtered)',
167
- noResultsTemplate: 'No items found',
168
- formatter: num => num.toLocaleString()
169
- },
170
- // Styles
171
- styles: {
172
- slider: {
173
- // Add new UI options
174
- ui: {
175
- showHistogram: false,
176
- bins: 10,
177
- // Number of bins for histogram
178
- track: {
179
- radius: '2px',
180
- // Button radius
181
- background: '#e5e7eb' // Track color
182
- },
183
- thumb: {
184
- radius: '50%',
185
- // Button radius
186
- size: '16px',
187
- // Button size
188
- background: '#000' // Button color
189
- },
190
- histogram: {
191
- background: '#e5e7eb',
192
- // Histogram background
193
- bar: {
194
- background: '#000' // Bar color
195
- }
196
- }
197
- }
198
- },
199
- pagination: {
200
- ui: {
201
- button: {
202
- background: 'transparent',
203
- border: '1px solid #000',
204
- borderRadius: '4px',
205
- padding: '8px 12px',
206
- color: '#000',
207
- active: {
208
- background: '#000',
209
- color: '#fff'
210
- },
211
- hover: {
212
- background: '#000',
213
- color: '#fff'
214
- }
215
- }
216
- }
217
- },
218
- colors: {
219
- primary: '#000',
220
- background: '#e5e7eb',
221
- text: '#000'
222
- }
223
- },
224
- // Slider
225
- slider: {
226
- containerClass: 'afs-range-slider',
227
- trackClass: 'afs-range-track',
228
- thumbClass: 'afs-range-thumb',
229
- valueClass: 'afs-range-value',
230
- selectedClass: 'afs-range-selected'
231
- },
232
- // Pagination
233
- pagination: {
234
- enabled: false,
235
- itemsPerPage: 10,
236
- container: '.afs-pagination-container',
237
- pageButtonClass: 'afs-page-button',
238
- activePageClass: 'afs-page-active',
239
- containerClass: 'afs-pagination',
240
- scrollToTop: false,
241
- scrollOffset: 50,
242
- scrollBehavior: 'smooth' // or 'auto' for instant scroll
243
- }
244
- };
245
- constructor() {
246
- let userOptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
247
- this.options = this.mergeOptions(Options.defaults, userOptions);
248
- this.initializeStyles();
249
- this.validate();
250
- }
251
-
252
- /**
253
- * Initialize styles with defaults
254
- * @private
255
- */
256
- initializeStyles() {
257
- const defaultStyles = Options.defaults.styles;
258
- const currentStyles = this.options.styles || {};
259
- this.options.styles = this.mergeOptions(defaultStyles, currentStyles);
260
-
261
- // Ensure colors object exists and has all required properties
262
- this.options.styles.colors = {
263
- ...defaultStyles.colors,
264
- ...(currentStyles.colors || {})
265
- };
266
- }
267
-
268
- /**
269
- * Deep merge options
270
- * @private
271
- * @param {Object} target - Target object
272
- * @param {Object} source - Source object
273
- * @returns {Object} Merged options
274
- */
275
- mergeOptions(target, source) {
276
- const result = {
277
- ...target
278
- };
279
- for (const key in source) {
280
- if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
281
- // If the key doesn't exist in target or isn't an object, create/override it
282
- if (!target[key] || typeof target[key] !== 'object') {
283
- result[key] = {};
284
- }
285
- // Recursively merge nested objects
286
- result[key] = this.mergeOptions(result[key], source[key]);
287
- } else if (source[key] !== undefined) {
288
- // Only override if the source value is defined
289
- result[key] = source[key];
290
- }
291
- }
292
- return result;
293
- }
294
-
295
- /**
296
- * Validate options
297
- * @private
298
- * @throws {Error} If options are invalid
299
- */
300
- validate() {
301
- // Required selectors
302
- const requiredSelectors = ['containerSelector', 'itemSelector'];
303
- for (const selector of requiredSelectors) {
304
- if (typeof this.options[selector] !== 'string') {
305
- throw new Error(`${selector} must be a string`);
306
- }
307
- }
308
-
309
- // Animation duration
310
- if (typeof this.options.animationDuration !== 'number' || this.options.animationDuration < 0) {
311
- throw new Error('animationDuration must be a positive number');
312
- }
313
-
314
- // Filter mode
315
- if (!['OR', 'AND'].includes(this.options.filterMode.toUpperCase())) {
316
- throw new Error('filterMode must be either "OR" or "AND"');
317
- }
318
-
319
- // Search keys
320
- if (!Array.isArray(this.options.searchKeys) || this.options.searchKeys.length === 0) {
321
- throw new Error('searchKeys must be a non-empty array');
322
- }
323
-
324
- // Counter validation
325
- if (this.options.counter) {
326
- if (typeof this.options.counter.template !== 'string') {
327
- throw new Error('counter.template must be a string');
328
- }
329
- if (typeof this.options.counter.showFiltered !== 'boolean') {
330
- this.options.counter.showFiltered = true; // Set default
331
- }
332
- if (typeof this.options.counter.formatter !== 'function') {
333
- this.options.counter.formatter = num => num.toLocaleString(); // Set default
334
- }
335
- } else {
336
- this.options.counter = {
337
- ...Options.defaults.counter
338
- }; // Set defaults if missing
339
- }
340
- }
341
-
342
- /**
343
- * Get option value
344
- * @param {string} path - Dot notation path to option
345
- * @returns {any} Option value
346
- */
347
- get(path) {
348
- return path.split('.').reduce((obj, key) => obj?.[key], this.options);
349
- }
350
-
351
- /**
352
- * Set option value
353
- * @param {string} path - Dot notation path to option
354
- * @param {any} value - New value
355
- */
356
- set(path, value) {
357
- const parts = path.split('.');
358
- const last = parts.pop();
359
- const target = parts.reduce((obj, key) => {
360
- if (!(key in obj)) obj[key] = {};
361
- return obj[key];
362
- }, this.options);
363
- target[last] = value;
364
- this.validate();
365
- }
366
-
367
- /**
368
- * Update multiple options
369
- * @param {Object} updates - Options to update
370
- */
371
- update(updates) {
372
- this.options = this.mergeOptions(this.options, updates);
373
- this.validate();
374
- }
375
-
376
- /**
377
- * Reset options to defaults
378
- */
379
- reset() {
380
- this.options = {
381
- ...Options.defaults
382
- };
383
- }
384
-
385
- /**
386
- * Export options
387
- * @returns {Object} Current options
388
- */
389
- export() {
390
- return {
391
- ...this.options
392
- };
393
- }
394
- }
395
-
396
- /**
397
- * @fileoverview State management for AFS
398
- */
399
-
400
- class State {
401
- constructor() {
402
- this.state = {
403
- filters: {
404
- current: new Set(['*']),
405
- groups: new Map(),
406
- ranges: new Map(),
407
- dateRanges: new Map(),
408
- mode: 'OR',
409
- groupMode: 'OR'
410
- },
411
- search: {
412
- query: '',
413
- keys: ['title']
414
- },
415
- sort: {
416
- orders: {},
417
- current: null
418
- },
419
- items: {
420
- visible: new Set(),
421
- total: 0
422
- },
423
- pagination: {
424
- currentPage: 1,
425
- itemsPerPage: 10,
426
- totalPages: 0
427
- }
428
- };
429
- }
430
-
431
- /**
432
- * Get current state
433
- * @returns {Object} Current state
434
- */
435
- getState() {
436
- return this.state;
437
- }
438
-
439
- /**
440
- * Update state
441
- * @param {string} path - Dot notation path to update
442
- * @param {any} value - New value
443
- */
444
- setState(path, value) {
445
- const parts = path.split('.');
446
- let current = this.state;
447
- for (let i = 0; i < parts.length - 1; i++) {
448
- if (!(parts[i] in current)) {
449
- current[parts[i]] = {};
450
- }
451
- current = current[parts[i]];
452
- }
453
- current[parts[parts.length - 1]] = value;
454
- }
455
-
456
- /**
457
- * Export state
458
- * @returns {Object} Exportable state
459
- */
460
- export() {
461
- return {
462
- filters: {
463
- current: Array.from(this.state.filters.current),
464
- groups: Array.from(this.state.filters.groups.entries()),
465
- ranges: Array.from(this.state.filters.ranges.entries()),
466
- dateRanges: Array.from(this.state.filters.dateRanges.entries()),
467
- mode: this.state.filters.mode,
468
- groupMode: this.state.filters.groupMode
469
- },
470
- search: {
471
- ...this.state.search
472
- },
473
- sort: {
474
- ...this.state.sort
475
- },
476
- pagination: {
477
- ...this.state.pagination
478
- }
479
- };
480
- }
481
-
482
- /**
483
- * Import state
484
- * @param {Object} importedState - State to import
485
- */
486
- import(importedState) {
487
- if (importedState.filters) {
488
- this.state.filters.current = new Set(importedState.filters.current);
489
- this.state.filters.groups = new Map(importedState.filters.groups);
490
- this.state.filters.ranges = new Map(importedState.filters.ranges);
491
- this.state.filters.dateRanges = new Map(importedState.filters.dateRanges);
492
- this.state.filters.mode = importedState.filters.mode;
493
- this.state.filters.groupMode = importedState.filters.groupMode;
494
- }
495
- if (importedState.search) {
496
- this.state.search = {
497
- ...importedState.search
498
- };
499
- }
500
- if (importedState.sort) {
501
- this.state.sort = {
502
- ...importedState.sort
503
- };
504
- }
505
- if (importedState.pagination) {
506
- this.state.pagination = {
507
- ...importedState.pagination
508
- };
509
- }
510
- }
511
-
512
- /**
513
- * Reset state to initial values
514
- */
515
- reset() {
516
- this.state = {
517
- filters: {
518
- current: new Set(['*']),
519
- groups: new Map(),
520
- ranges: new Map(),
521
- dateRanges: new Map(),
522
- mode: 'OR',
523
- groupMode: 'OR'
524
- },
525
- search: {
526
- query: '',
527
- keys: ['title']
528
- },
529
- sort: {
530
- orders: {},
531
- current: null
532
- },
533
- items: {
534
- visible: new Set(),
535
- total: 0
536
- },
537
- pagination: {
538
- currentPage: 1,
539
- itemsPerPage: 10,
540
- totalPages: 0
541
- }
542
- };
543
- }
544
- }
545
-
546
- /**
547
- * @fileoverview Style management for AFS
548
- */
549
-
550
- class StyleManager {
551
- /**
552
- * @param {import('../core/Options').Options} options - Options instance
553
- */
554
- constructor(options) {
555
- this.options = options;
556
- this.styleElement = null;
557
- }
558
-
559
- /**
560
- * Create base styles
561
- * @private
562
- * @returns {string} CSS styles
563
- */
564
- createBaseStyles() {
565
- const hiddenClass = this.options.get("hiddenClass") || "hidden";
566
- const itemSelector = this.options.get("itemSelector") || ".afs-filter-item";
567
- const filterButtonSelector = this.options.get("filterButtonSelector") || ".afs-btn-filter";
568
- const activeClass = this.options.get("activeClass") || "active";
569
- const animationDuration = this.options.get("animationDuration") || '300ms';
570
- const animationEasing = this.options.get("animationEasing") || 'ease-out';
571
- return `
572
- .${hiddenClass} {
573
- display: none !important;
574
- }
575
-
576
- ${itemSelector} {
577
- opacity: 1;
578
- transform: scale(1);
579
- filter: blur(0);
580
- transition: opacity ${animationDuration} ${animationEasing},
581
- transform ${animationDuration} ${animationEasing},
582
- filter ${animationDuration} ${animationEasing};
583
- }
584
-
585
- ${itemSelector}.${hiddenClass} {
586
- opacity: 0;
587
- transform: scale(0.95);
588
- filter: blur(5px);
589
- }
590
-
591
- ${filterButtonSelector} {
592
- opacity: 0.5;
593
- transition: opacity ${animationDuration} ${animationEasing};
594
- }
595
-
596
- ${filterButtonSelector}.${activeClass} {
597
- opacity: 1;
598
- }
599
- `;
600
- }
601
-
602
- /**
603
- * Create range slider styles
604
- * @private
605
- * @returns {string} CSS styles
606
- */
607
- createRangeStyles() {
608
- const styles = this.options.get("styles");
609
- const sliderOptions = this.options.get("slider") || {};
610
- const sliderStyles = styles.slider;
611
- const colors = styles.colors;
612
- const containerClass = sliderOptions.containerClass || "afs-range-slider";
613
- const trackClass = sliderOptions.trackClass || "afs-range-track";
614
- const thumbClass = sliderOptions.thumbClass || "afs-range-thumb";
615
- const valueClass = sliderOptions.valueClass || "afs-range-value";
616
- const selectedClass = sliderOptions.selectedClass || "afs-range-selected";
617
- return `
618
- /* Range Slider Styles */
619
- .${containerClass} {
620
- position: relative;
621
- width: auto;
622
- height: 40px;
623
- margin: 10px 0;
624
- padding: 0 8px;
625
- }
626
-
627
- .${trackClass} {
628
- position: absolute;
629
- top: 50%;
630
- transform: translateY(-50%);
631
- width: 100%;
632
- height: 4px;
633
- background: ${sliderStyles.ui.thumb.background || colors.background};
634
- border-radius: ${sliderStyles.ui.track.radius || "2px"};
635
- }
636
-
637
- .${thumbClass} {
638
- position: absolute;
639
- top: 50%;
640
- width: ${sliderStyles.ui.thumb.size || "16px"};
641
- height: ${sliderStyles.ui.thumb.size || "16px"};
642
- background: ${sliderStyles.ui.thumb.background || colors.primary};
643
- border-radius: ${sliderStyles.ui.thumb.radius || "50%"};
644
- transform: translate(-50%, -50%);
645
- cursor: pointer;
646
- z-index: 2;
647
- }
648
-
649
- .${valueClass} {
650
- position: absolute;
651
- top: -20px;
652
- transform: translateX(-50%);
653
- font-size: 10px;
654
- color: ${colors.text};
655
- }
656
-
657
- .${selectedClass} {
658
- position: absolute;
659
- height: 4px;
660
- background: ${colors.primary};
661
- top: 50%;
662
- transform: translateY(-50%);
663
- }
664
-
665
- /* Histogram Styles */
666
- .afs-histogram {
667
- position: absolute;
668
- bottom: 22px;
669
- left: 8px;
670
- right: 8px;
671
- height: 20px;
672
- display: flex;
673
- align-items: flex-end;
674
- gap: 1px;
675
- opacity: 0.5;
676
- }
677
-
678
- .afs-histogram-bar {
679
- flex: 1;
680
- background-color: ${sliderStyles.ui.histogram.background || colors.background};
681
- min-height: 4px;
682
- transition: background-color 0.2s ease;
683
- }
684
-
685
- .afs-histogram-bar.active {
686
- background-color: ${sliderStyles.ui.histogram.bar.background || colors.primary};
687
- }
688
- `;
689
- }
690
-
691
- /**
692
- * Create date filter styles
693
- * @private
694
- * @returns {string} CSS styles
695
- */
696
- createDateStyles() {
697
- const colors = this.options.get("styles").colors;
698
- return `
699
- .afs-date-range-container {
700
- display: flex;
701
- flex-wrap: wrap;
702
- gap: 1rem;
703
- margin: 10px 0;
704
- }
705
-
706
- .afs-date-input-wrapper {
707
- flex: 1;
708
- }
709
-
710
- .afs-date-input-wrapper label {
711
- display: block;
712
- font-size: 0.875rem;
713
- color: ${colors.text};
714
- margin-bottom: 0.5rem;
715
- }
716
-
717
- .afs-date-input {
718
- width: 100%;
719
- padding: 0.5rem;
720
- border: 1px solid ${colors.background};
721
- border-radius: 0.25rem;
722
- font-size: 0.875rem;
723
- color: ${colors.text};
724
- transition: border-color 0.2s ease;
725
- }
726
-
727
- .afs-date-input:focus {
728
- outline: none;
729
- border-color: ${colors.primary};
730
- }
731
- `;
732
- }
733
-
734
- /**
735
- * Apply all styles
736
- * @public
737
- */
738
- applyStyles() {
739
- try {
740
- const styles = `
741
- /* Base styles */
742
- ${this.createBaseStyles()}
743
-
744
- /* Range slider styles */
745
- ${this.createRangeStyles()}
746
-
747
- /* Date filter styles */
748
- ${this.createDateStyles()}
749
-
750
- /* Pagination styles */
751
- ${this.createPaginationStyles()}
752
-
753
- /* Search styles */
754
- ${this.createSearchStyles()}
755
- `;
756
- if (this.styleElement) {
757
- this.styleElement.textContent = styles;
758
- } else {
759
- this.styleElement = document.createElement("style");
760
- this.styleElement.textContent = styles;
761
- document.head.appendChild(this.styleElement);
762
- }
763
- } catch (error) {
764
- console.error("Error applying styles:", error);
765
- const fallbackStyles = this.createBaseStyles();
766
- if (this.styleElement) {
767
- this.styleElement.textContent = fallbackStyles;
768
- } else {
769
- this.styleElement = document.createElement("style");
770
- this.styleElement.textContent = fallbackStyles;
771
- document.head.appendChild(this.styleElement);
772
- }
773
- }
774
- }
775
-
776
- /**
777
- * Create pagination styles
778
- * @private
779
- * @returns {string} CSS styles
780
- */
781
- createPaginationStyles() {
782
- const styles = this.options.get("styles");
783
- const paginationOptions = this.options.get("pagination") || {};
784
- const colors = this.options.get("styles").colors;
785
- const containerClass = paginationOptions.containerClass || "afs-pagination";
786
- const buttonClass = paginationOptions.pageButtonClass || "afs-page-button";
787
- const activeClass = paginationOptions.activePageClass || "afs-page-active";
788
- const paginationStyles = styles.pagination;
789
- return `
790
- .${containerClass} {
791
- display: flex;
792
- justify-content: center;
793
- gap: 8px;
794
- margin-top: 20px;
795
- }
796
-
797
- .${buttonClass} {
798
- padding: ${paginationStyles.ui.button.padding || '8px 12px'};
799
- border: ${paginationStyles.ui.button.border || '1px solid ' + colors.primary};
800
- border-radius: ${paginationStyles.ui.button.borderRadius || '4px'};
801
- cursor: pointer;
802
- transition: all 200ms ease-out;
803
- background: ${paginationStyles.ui.button.background || 'transparent'};
804
- color: ${paginationStyles.ui.button.color || colors.primary};
805
- }
806
-
807
- .${buttonClass}:hover {
808
- background: ${paginationStyles.ui.button.hover.background || colors.primary};
809
- color: ${paginationStyles.ui.button.hover.color || 'white'};
810
- }
811
-
812
- .${buttonClass}.${activeClass} {
813
- background: ${paginationStyles.ui.button.active.background || colors.primary};
814
- color: ${paginationStyles.ui.button.active.color || 'white'};
815
- }
816
-
817
- .${buttonClass}:disabled {
818
- opacity: 0.5;
819
- cursor: not-allowed;
820
- }
821
- `;
822
- }
823
-
824
- /**
825
- * Create search styles
826
- * @private
827
- * @returns {string} CSS styles
828
- */
829
- createSearchStyles() {
830
- const searchClass = this.options.get("searchInputClass") || "afs-search";
831
- const colors = this.options.get("styles").colors;
832
- return `
833
- .${searchClass} {
834
- padding: 8px;
835
- border: 1px solid ${colors.background};
836
- border-radius: 4px;
837
- width: 100%;
838
- max-width: 300px;
839
- transition: border-color 200ms ease-out;
840
- }
841
-
842
- .${searchClass}:focus {
843
- outline: none;
844
- border-color: ${colors.primary};
845
- }
846
- `;
847
- }
848
-
849
- /**
850
- * Update styles
851
- * @public
852
- * @param {Object} newOptions - New style options
853
- */
854
- updateStyles(newOptions) {
855
- this.options = newOptions;
856
- this.applyStyles();
857
- }
858
-
859
- /**
860
- * Remove styles
861
- * @public
862
- */
863
- removeStyles() {
864
- if (this.styleElement) {
865
- this.styleElement.remove();
866
- this.styleElement = null;
867
- }
868
- }
869
- }
870
-
871
- /**
872
- * @fileoverview Event handling system for AFS
873
- */
874
-
875
- class EventEmitter {
876
- constructor() {
877
- this.events = new Map();
878
- this.onceEvents = new Map();
879
- }
880
-
881
- /**
882
- * Subscribe to an event
883
- * @param {string} eventName - Name of the event
884
- * @param {Function} callback - Callback function
885
- * @returns {Function} Unsubscribe function
886
- */
887
- on(eventName, callback) {
888
- if (!this.events.has(eventName)) {
889
- this.events.set(eventName, new Set());
890
- }
891
- this.events.get(eventName).add(callback);
892
-
893
- // Return unsubscribe function
894
- return () => this.off(eventName, callback);
895
- }
896
-
897
- /**
898
- * Subscribe to an event once
899
- * @param {string} eventName - Name of the event
900
- * @param {Function} callback - Callback function
901
- * @returns {Function} Unsubscribe function
902
- */
903
- once(eventName, callback) {
904
- var _this = this;
905
- const onceWrapper = function () {
906
- _this.off(eventName, onceWrapper);
907
- for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
908
- args[_key] = arguments[_key];
909
- }
910
- callback.apply(_this, args);
911
- };
912
- if (!this.onceEvents.has(eventName)) {
913
- this.onceEvents.set(eventName, new Map());
914
- }
915
- this.onceEvents.get(eventName).set(callback, onceWrapper);
916
- return this.on(eventName, onceWrapper);
917
- }
918
-
919
- /**
920
- * Unsubscribe from an event
921
- * @param {string} eventName - Name of the event
922
- * @param {Function} callback - Callback function
923
- */
924
- off(eventName, callback) {
925
- // Remove from regular events
926
- if (this.events.has(eventName)) {
927
- this.events.get(eventName).delete(callback);
928
-
929
- // Cleanup if no more listeners
930
- if (this.events.get(eventName).size === 0) {
931
- this.events.delete(eventName);
932
- }
933
- }
934
-
935
- // Remove from once events
936
- if (this.onceEvents.has(eventName)) {
937
- const onceWrapper = this.onceEvents.get(eventName).get(callback);
938
- if (onceWrapper) {
939
- this.events.get(eventName)?.delete(onceWrapper);
940
- this.onceEvents.get(eventName).delete(callback);
941
- }
942
-
943
- // Cleanup if no more once listeners
944
- if (this.onceEvents.get(eventName).size === 0) {
945
- this.onceEvents.delete(eventName);
946
- }
947
- }
948
- }
949
-
950
- /**
951
- * Emit an event
952
- * @param {string} eventName - Name of the event
953
- * @param {...any} args - Arguments to pass to callbacks
954
- */
955
- emit(eventName) {
956
- for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
957
- args[_key2 - 1] = arguments[_key2];
958
- }
959
- if (this.events.has(eventName)) {
960
- this.events.get(eventName).forEach(callback => {
961
- try {
962
- callback.apply(this, args);
963
- } catch (error) {
964
- console.error(`Error in event handler for ${eventName}:`, error);
965
- }
966
- });
967
- }
968
- }
969
-
970
- /**
971
- * Remove all event listeners
972
- * @param {string} [eventName] - Optional event name to clear specific event
973
- */
974
- removeAllListeners(eventName) {
975
- if (eventName) {
976
- this.events.delete(eventName);
977
- this.onceEvents.delete(eventName);
978
- } else {
979
- this.events.clear();
980
- this.onceEvents.clear();
981
- }
982
- }
983
-
984
- /**
985
- * Get the number of listeners for an event
986
- * @param {string} eventName - Name of the event
987
- * @returns {number} Number of listeners
988
- */
989
- listenerCount(eventName) {
990
- return (this.events.get(eventName)?.size || 0) + (this.onceEvents.get(eventName)?.size || 0);
991
- }
992
- }
993
-
994
- /**
995
- * @fileoverview Animation management for AFS
996
- */
997
-
998
- class Animation {
999
- constructor(afs) {
1000
- this.afs = afs;
1001
- this.options = this.afs.options;
1002
- this.animations = {
1003
- fade: {
1004
- in: {
1005
- opacity: 1,
1006
- transform: 'scale(1)',
1007
- transitionTimingFunction: 'ease-in'
1008
- },
1009
- out: {
1010
- opacity: 0,
1011
- transform: 'scale(0.95)',
1012
- transitionTimingFunction: 'ease-out'
1013
- }
1014
- },
1015
- slide: {
1016
- in: {
1017
- opacity: 1,
1018
- transform: 'translateY(0)',
1019
- transitionTimingFunction: 'ease-in-out'
1020
- },
1021
- out: {
1022
- opacity: 0,
1023
- transform: 'translateY(20px)',
1024
- transitionTimingFunction: 'ease-in-out'
1025
- }
1026
- },
1027
- scale: {
1028
- in: {
1029
- opacity: 1,
1030
- transform: 'scale(1)',
1031
- transitionTimingFunction: 'ease-in'
1032
- },
1033
- out: {
1034
- opacity: 0,
1035
- transform: 'scale(0.8)',
1036
- transitionTimingFunction: 'ease-out'
1037
- }
1038
- },
1039
- rotate: {
1040
- in: {
1041
- opacity: 1,
1042
- transform: 'rotate(0deg) scale(1)',
1043
- transitionTimingFunction: 'ease-in'
1044
- },
1045
- out: {
1046
- opacity: 0,
1047
- transform: 'rotate(90deg) scale(0.9)',
1048
- transitionTimingFunction: 'ease-out'
1049
- }
1050
- },
1051
- flip: {
1052
- in: {
1053
- opacity: 1,
1054
- transform: 'rotateY(0)',
1055
- transitionTimingFunction: 'ease-in'
1056
- },
1057
- out: {
1058
- opacity: 0,
1059
- transform: 'rotateY(180deg)',
1060
- transitionTimingFunction: 'ease-out'
1061
- }
1062
- },
1063
- zoom: {
1064
- in: {
1065
- opacity: 1,
1066
- transform: 'scale(1.2)',
1067
- transitionTimingFunction: 'ease-in'
1068
- },
1069
- out: {
1070
- opacity: 0,
1071
- transform: 'scale(0.8)',
1072
- transitionTimingFunction: 'ease-out'
1073
- }
1074
- },
1075
- bounce: {
1076
- in: {
1077
- opacity: 1,
1078
- transform: 'translateY(0)',
1079
- animation: 'bounce 1s cubic-bezier(0.68, -0.55, 0.27, 1.55)'
1080
- },
1081
- out: {
1082
- opacity: 0,
1083
- transform: 'translateY(-20px)',
1084
- animation: 'bounceOut 1s ease-out'
1085
- }
1086
- },
1087
- blur: {
1088
- in: {
1089
- opacity: 1,
1090
- filter: 'blur(0)',
1091
- transitionTimingFunction: 'ease-in'
1092
- },
1093
- out: {
1094
- opacity: 0,
1095
- filter: 'blur(5px)',
1096
- transitionTimingFunction: 'ease-out'
1097
- }
1098
- },
1099
- skew: {
1100
- in: {
1101
- opacity: 1,
1102
- transform: 'skew(0deg)',
1103
- transitionTimingFunction: 'ease-in-out'
1104
- },
1105
- out: {
1106
- opacity: 0,
1107
- transform: 'skew(10deg)',
1108
- transitionTimingFunction: 'ease-in-out'
1109
- }
1110
- },
1111
- slideInLeft: {
1112
- in: {
1113
- opacity: 1,
1114
- transform: 'translateX(0)',
1115
- transitionTimingFunction: 'ease-in'
1116
- },
1117
- out: {
1118
- opacity: 0,
1119
- transform: 'translateX(-100%)',
1120
- transitionTimingFunction: 'ease-out'
1121
- }
1122
- },
1123
- slideInRight: {
1124
- in: {
1125
- opacity: 1,
1126
- transform: 'translateX(0)',
1127
- transitionTimingFunction: 'ease-in'
1128
- },
1129
- out: {
1130
- opacity: 0,
1131
- transform: 'translateX(100%)',
1132
- transitionTimingFunction: 'ease-out'
1133
- }
1134
- },
1135
- fadeInUp: {
1136
- in: {
1137
- opacity: 1,
1138
- transform: 'translateY(0)',
1139
- transitionTimingFunction: 'ease-in'
1140
- },
1141
- out: {
1142
- opacity: 0,
1143
- transform: 'translateY(10px)',
1144
- transitionTimingFunction: 'ease-out'
1145
- }
1146
- },
1147
- fadeInDown: {
1148
- in: {
1149
- opacity: 1,
1150
- transform: 'translateY(0)',
1151
- transitionTimingFunction: 'ease-in'
1152
- },
1153
- out: {
1154
- opacity: 0,
1155
- transform: 'translateY(-10px)',
1156
- transitionTimingFunction: 'ease-out'
1157
- }
1158
- },
1159
- bounceIn: {
1160
- in: {
1161
- opacity: 1,
1162
- transform: 'scale(1.05)',
1163
- transitionTimingFunction: 'cubic-bezier(0.68, -0.55, 0.27, 1.55)'
1164
- },
1165
- out: {
1166
- opacity: 0,
1167
- transform: 'scale(0.9)',
1168
- transitionTimingFunction: 'ease-out'
1169
- }
1170
- }
1171
- };
1172
- }
1173
- applyShowAnimation(item) {
1174
- let animationType = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'fade';
1175
- const duration = this.options.get('animationDuration') || '300ms';
1176
- const animation = this.animations[animationType]?.in || this.animations.fade.in;
1177
- item.style.display = 'block';
1178
- requestAnimationFrame(() => {
1179
- Object.assign(item.style, animation, {
1180
- transition: `opacity ${duration} ${animation.transitionTimingFunction}, transform ${duration} ${animation.transitionTimingFunction}`
1181
- });
1182
- });
1183
- }
1184
- applyHideAnimation(item) {
1185
- let animationType = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'fade';
1186
- const duration = this.options.get('animationDuration') || '300ms';
1187
- const animation = this.animations[animationType]?.out || this.animations.fade.out;
1188
- Object.assign(item.style, animation, {
1189
- transition: `opacity ${duration} ${animation.transitionTimingFunction}, transform ${duration} ${animation.transitionTimingFunction}`
1190
- });
1191
- const handleTransitionEnd = () => {
1192
- item.style.display = 'none';
1193
- item.removeEventListener('transitionend', handleTransitionEnd);
1194
- };
1195
- item.addEventListener('transitionend', handleTransitionEnd);
1196
- }
1197
- setAnimation(animationType) {
1198
- this.afs.options.set('animationType', animationType);
1199
- }
1200
- }
1201
-
1202
- /**
1203
- * @fileoverview Filter functionality for AFS
1204
- */
1205
-
1206
- class Filter {
1207
- /**
1208
- * @param {import('../AFS').AFS} afs - Main AFS instance
1209
- */
1210
- constructor(afs) {
1211
- this.afs = afs;
1212
- this.animation = new Animation(afs);
1213
- this.filterButtons = new Map();
1214
- this.activeFilters = new Set(["*"]);
1215
- this.currentFilters = new Set(["*"]);
1216
- this.filterGroups = new Map();
1217
- this.sortOrders = new Map();
1218
- this.setupFilters();
1219
- }
1220
-
1221
- /**
1222
- * Setup filters
1223
- * @private
1224
- */
1225
- setupFilters() {
1226
- this.afs.logger.debug("Setting up filters");
1227
- const filterSelector = this.afs.options.get("filterButtonSelector");
1228
- if (!filterSelector) return;
1229
-
1230
- // Initialize filter buttons
1231
- document.querySelectorAll(filterSelector).forEach(button => {
1232
- const filterValue = button.dataset.filter;
1233
- if (!filterValue) {
1234
- this.afs.logger.warn("Filter button missing data-filter attribute:", button);
1235
- return;
1236
- }
1237
- this.filterButtons.set(button, filterValue);
1238
- this.bindFilterEvent(button);
1239
- });
1240
- this.afs.logger.debug("Filters initialized");
1241
- }
1242
-
1243
- /**
1244
- * Set filter logic mode
1245
- * @public
1246
- * @param {string|boolean} logic - 'AND'/'OR' or true/false (true = AND, false = OR)
1247
- */
1248
- setLogic(logic) {
1249
- if (typeof logic === "boolean") {
1250
- // Handle boolean input (true = AND, false = OR)
1251
- this.afs.options.set("filterMode", logic ? "AND" : "OR");
1252
- } else {
1253
- const mode = logic.toUpperCase();
1254
- if (["OR", "AND"].includes(mode)) {
1255
- this.afs.options.set("filterMode", mode);
1256
- } else {
1257
- this.afs.logger.warn(`Invalid filter mode: ${logic}`);
1258
- return;
1259
- }
1260
- }
1261
- this.afs.logger.debug(`Filter logic set to: ${this.afs.options.get("filterMode")}`);
1262
- this.applyFilters();
1263
- }
1264
-
1265
- /**
1266
- * Clear all filters
1267
- * @public
1268
- */
1269
- clearAllFilters() {
1270
- this.afs.logger.debug("Clearing all filters");
1271
-
1272
- // Reset filters
1273
- this.activeFilters.clear();
1274
- this.activeFilters.add("*");
1275
-
1276
- // Reset filter buttons
1277
- this.filterButtons.forEach((_, button) => {
1278
- button.classList.remove(this.afs.options.get("activeClass"));
1279
- });
1280
-
1281
- // Reset filter groups
1282
- this.filterGroups.clear();
1283
-
1284
- // Find and activate "all" button if exists
1285
- const allButton = this.findAllButton();
1286
- if (allButton) {
1287
- allButton.classList.add(this.afs.options.get("activeClass"));
1288
- }
1289
-
1290
- // Clear sorting
1291
- this.sortOrders.clear();
1292
-
1293
- // Apply changes and update UI
1294
- this.applyFilters();
1295
- this.afs.urlManager.updateURL();
1296
- this.afs.emit("filtersCleared");
1297
- }
1298
-
1299
- /**
1300
- * Bind filter event to button
1301
- * @private
1302
- * @param {HTMLElement} button - Filter button
1303
- */
1304
- bindFilterEvent(button) {
1305
- this.afs.logger.debug("Binding filter event to button:", button);
1306
- button.addEventListener("click", () => {
1307
- const filterValue = this.filterButtons.get(button);
1308
- if (!filterValue) return;
1309
- if (filterValue === "*") {
1310
- this.resetFilters();
1311
- } else {
1312
- this.toggleFilter(filterValue, button);
1313
- }
1314
-
1315
- // Update the URL after filter change
1316
- this.afs.urlManager.updateURL();
1317
- });
1318
- }
1319
-
1320
- /**
1321
- * Reset filters to default state
1322
- * @public
1323
- */
1324
- resetFilters() {
1325
- this.afs.logger.debug("Resetting filters");
1326
-
1327
- // Clear existing filters
1328
- this.activeFilters.clear();
1329
-
1330
- // Reset button states
1331
- this.filterButtons.forEach((_, button) => {
1332
- button.classList.remove(this.afs.options.get("activeClass"));
1333
- });
1334
-
1335
- // Add "*" filter and activate "all" button
1336
- this.activeFilters.add("*");
1337
- const allButton = this.findAllButton();
1338
- if (allButton) {
1339
- allButton.classList.add(this.afs.options.get("activeClass"));
1340
- }
1341
-
1342
- // Clear filter groups
1343
- this.filterGroups.clear();
1344
-
1345
- // Force all items to be visible first
1346
- const visibleItems = new Set(this.afs.items);
1347
- this.afs.state.setState("items.visible", visibleItems);
1348
-
1349
- // Show all items with animation
1350
- this.afs.items.forEach(item => {
1351
- item.classList.remove(this.afs.options.get('hiddenClass'));
1352
- requestAnimationFrame(() => {
1353
- this.animation.applyShowAnimation(item, this.afs.options.get("animationType"));
1354
- });
1355
- });
1356
-
1357
- // Update counter after reset
1358
- this.afs.updateCounter();
1359
-
1360
- // Update URL after reset
1361
- this.afs.urlManager.updateURL();
1362
-
1363
- // Emit event
1364
- this.afs.emit("filtersReset");
1365
- }
1366
-
1367
- /**
1368
- * Find "all" filter button
1369
- * @private
1370
- * @returns {HTMLElement|null} All button
1371
- */
1372
- findAllButton() {
1373
- for (const [button, value] of this.filterButtons.entries()) {
1374
- if (value === "*") return button;
1375
- }
1376
- return null;
1377
- }
1378
- handleFilterClick(button) {
1379
- const filterValue = button.dataset.filter;
1380
- this.afs.logger.debug("Filter clicked:", filterValue);
1381
- if (filterValue === "*") {
1382
- this.resetFilters();
1383
- } else {
1384
- this.toggleFilter(filterValue, button);
1385
- }
1386
- this.filter();
1387
-
1388
- // Call updateURL to reflect the new filter state in the URL
1389
- this.updateURL();
1390
- }
1391
-
1392
- /**
1393
- * Toggle filter state
1394
- * @private
1395
- * @param {string} filterValue - Filter value
1396
- * @param {HTMLElement} button - Filter button
1397
- */
1398
- toggleFilter(filterValue, button) {
1399
- // Remove "all" filter
1400
- this.activeFilters.delete("*");
1401
- const allButton = this.findAllButton();
1402
- if (allButton) {
1403
- allButton.classList.remove(this.afs.options.get("activeClass"));
1404
- }
1405
-
1406
- // Toggle filter
1407
- if (button.classList.contains(this.afs.options.get("activeClass"))) {
1408
- button.classList.remove(this.afs.options.get("activeClass"));
1409
- this.activeFilters.delete(filterValue);
1410
-
1411
- // Reset to "all" if no filters active
1412
- if (this.activeFilters.size === 0) {
1413
- this.resetFilters();
1414
- return;
1415
- }
1416
- } else {
1417
- button.classList.add(this.afs.options.get("activeClass"));
1418
- this.activeFilters.add(filterValue);
1419
- }
1420
- this.applyFilters();
1421
-
1422
- // Emit event
1423
- this.afs.emit("filterToggled", {
1424
- filter: filterValue,
1425
- activeFilters: Array.from(this.activeFilters)
1426
- });
1427
- this.afs.logger.debug("Filter toggled:", filterValue);
1428
- }
1429
-
1430
- /**
1431
- * Apply current filters
1432
- * @public
1433
- */
1434
- applyFilters() {
1435
- // Log active filters
1436
- const activeFilters = Array.from(this.activeFilters);
1437
- this.afs.logger.debug("Active filters:", activeFilters);
1438
- this.afs.logger.debug("Applying filters");
1439
- const previouslyVisible = new Set(this.afs.state.getState().items.visible);
1440
- const visibleItems = new Set();
1441
-
1442
- // First pass: determine which items should be visible
1443
- this.afs.items.forEach(item => {
1444
- if (this.activeFilters.has("*") || this.itemMatchesFilters(item)) {
1445
- visibleItems.add(item);
1446
- }
1447
- });
1448
-
1449
- // Update state with determined visible items
1450
- this.afs.state.setState("items.visible", visibleItems);
1451
-
1452
- // Second pass: apply animations based on visibility changes
1453
- this.afs.items.forEach(item => {
1454
- if (visibleItems.has(item)) {
1455
- // Remove hidden class first
1456
- item.classList.remove(this.afs.options.get('hiddenClass'));
1457
- // Then apply show animation in next frame
1458
- requestAnimationFrame(() => {
1459
- this.animation.applyShowAnimation(item, this.afs.options.get("animationType"));
1460
- });
1461
- } else {
1462
- // Apply hide animation
1463
- requestAnimationFrame(() => {
1464
- this.animation.applyHideAnimation(item, this.afs.options.get("animationType"));
1465
- });
1466
- }
1467
- });
1468
-
1469
- // Emit events and update UI
1470
- this.emitFilterEvents(previouslyVisible, visibleItems);
1471
-
1472
- // Schedule UI updates
1473
- requestAnimationFrame(() => {
1474
- // Force reflow to ensure animations play
1475
- this.afs.container.offsetHeight;
1476
-
1477
- // Update counter - this needs to happen after filter changes
1478
- this.afs.updateCounter();
1479
-
1480
- // Update URL
1481
- this.afs.urlManager.updateURL();
1482
-
1483
- // Emit final event
1484
- this.afs.emit("filtersApplied", {
1485
- activeFilters,
1486
- visibleItems: visibleItems.size
1487
- });
1488
- });
1489
- }
1490
-
1491
- /**
1492
- * Check if item matches current filters
1493
- * @private
1494
- * @param {HTMLElement} item - DOM element
1495
- * @returns {boolean} Whether item matches filters
1496
- */
1497
- itemMatchesFilters(item) {
1498
- // Show all items if "*" is active
1499
- if (this.activeFilters.has("*")) {
1500
- return true;
1501
- }
1502
-
1503
- // Regular filter matching
1504
- const itemCategories = new Set(item.dataset.categories?.split(" ") || []);
1505
-
1506
- // If using filter groups
1507
- if (this.filterGroups.size > 0) {
1508
- return this.itemMatchesFilterGroups(itemCategories);
1509
- }
1510
-
1511
- // Regular filtering
1512
- return this.afs.options.get("filterMode") === "OR" ? this.itemMatchesAnyFilter(itemCategories) : this.itemMatchesAllFilters(itemCategories);
1513
- }
1514
-
1515
- /**
1516
- * Check if item matches any active filter (OR mode)
1517
- * @private
1518
- * @param {Set} itemCategories - Item's categories
1519
- * @returns {boolean} Whether item matches any filter
1520
- */
1521
- itemMatchesAnyFilter(itemCategories) {
1522
- return Array.from(this.activeFilters).some(filter => {
1523
- if (filter === "*") return true;
1524
- return itemCategories.has(filter);
1525
- });
1526
- }
1527
-
1528
- /**
1529
- * Check if item matches all active filters (AND mode)
1530
- * @private
1531
- * @param {Set} itemCategories - Item's categories
1532
- * @returns {boolean} Whether item matches all filters
1533
- */
1534
- itemMatchesAllFilters(itemCategories) {
1535
- return Array.from(this.activeFilters).every(filter => {
1536
- if (filter === "*") return true;
1537
- return itemCategories.has(filter);
1538
- });
1539
- }
1540
-
1541
- /**
1542
- * Check if item matches filter groups
1543
- * @private
1544
- * @param {Set} itemCategories - Item's categories
1545
- * @returns {boolean} Whether item matches groups
1546
- */
1547
- itemMatchesFilterGroups(itemCategories) {
1548
- const groupMatches = Array.from(this.filterGroups.values()).map(group => {
1549
- if (group.filters.size === 0) return true;
1550
- return group.operator === "OR" ? Array.from(group.filters).some(filter => itemCategories.has(filter)) : Array.from(group.filters).every(filter => itemCategories.has(filter));
1551
- });
1552
- return this.afs.options.get("groupMode") === "OR" ? groupMatches.some(matches => matches) : groupMatches.every(matches => matches);
1553
- }
1554
-
1555
- /**
1556
- * Emit filter-related events
1557
- * @private
1558
- * @param {Set} previouslyVisible - Previously visible items
1559
- * @param {Set} nowVisible - Currently visible items
1560
- */
1561
- emitFilterEvents(previouslyVisible, nowVisible) {
1562
- // Determine added and removed items
1563
- const added = new Set([...nowVisible].filter(item => !previouslyVisible.has(item)));
1564
- const removed = new Set([...previouslyVisible].filter(item => !nowVisible.has(item)));
1565
-
1566
- // Emit filter event
1567
- this.afs.emit("filter", {
1568
- activeFilters: Array.from(this.activeFilters),
1569
- visibleItems: nowVisible.size,
1570
- added: added.size,
1571
- removed: removed.size
1572
- });
1573
-
1574
- // Emit specific events for added/removed items
1575
- if (added.size > 0) {
1576
- this.afs.emit("itemsShown", {
1577
- items: added
1578
- });
1579
- }
1580
- if (removed.size > 0) {
1581
- this.afs.emit("itemsHidden", {
1582
- items: removed
1583
- });
1584
- }
1585
- }
1586
-
1587
- /**
1588
- * Add or update a filter group
1589
- * @public
1590
- * @param {string} groupId - Group identifier
1591
- * @param {string[]} filters - Array of filter values
1592
- * @param {string} [operator='OR'] - Operator within group
1593
- */
1594
- addFilterGroup(groupId, filters) {
1595
- let operator = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "OR";
1596
- this.afs.logger.debug(`Adding filter group: ${groupId}`);
1597
- if (!Array.isArray(filters)) {
1598
- this.afs.logger.error("Filters must be an array");
1599
- return;
1600
- }
1601
- const validOperator = operator.toUpperCase();
1602
- if (!["AND", "OR"].includes(validOperator)) {
1603
- this.afs.logger.warn(`Invalid operator: ${operator}, defaulting to OR`);
1604
- operator = "OR";
1605
- }
1606
- this.filterGroups.set(groupId, {
1607
- filters: new Set(filters),
1608
- operator: validOperator
1609
- });
1610
- this.applyFilters();
1611
- }
1612
-
1613
- /**
1614
- * Remove a filter group
1615
- * @public
1616
- * @param {string} groupId - Group identifier
1617
- */
1618
- removeFilterGroup(groupId) {
1619
- if (this.filterGroups.delete(groupId)) {
1620
- this.afs.logger.debug(`Removed filter group: ${groupId}`);
1621
- if (this.filterGroups.size === 0) {
1622
- this.resetFilters();
1623
- } else {
1624
- this.applyFilters();
1625
- }
1626
- }
1627
- }
1628
-
1629
- /**
1630
- * Set filter group mode
1631
- * @public
1632
- * @param {string} mode - Mode for combining groups
1633
- */
1634
- setGroupMode(mode) {
1635
- const validMode = mode.toUpperCase();
1636
- if (["AND", "OR"].includes(validMode)) {
1637
- this.afs.options.set("groupMode", validMode);
1638
- this.afs.logger.debug(`Set group mode to: ${validMode}`);
1639
- this.applyFilters();
1640
- } else {
1641
- this.afs.logger.warn(`Invalid group mode: ${mode}`);
1642
- }
1643
- }
1644
-
1645
- /**
1646
- * Add filter manually
1647
- * @public
1648
- * @param {string} filter - Filter value
1649
- */
1650
- addFilter(filter) {
1651
- this.afs.logger.debug(`Adding filter: ${filter}`);
1652
- if (filter === "*") {
1653
- this.resetFilters();
1654
- return;
1655
- }
1656
- this.activeFilters.delete("*");
1657
- this.activeFilters.add(filter);
1658
-
1659
- // Update button states
1660
- this.filterButtons.forEach((value, button) => {
1661
- if (value === filter) {
1662
- button.classList.add(this.afs.options.get("activeClass"));
1663
- } else if (value === "*") {
1664
- button.classList.remove(this.afs.options.get("activeClass"));
1665
- }
1666
- });
1667
- this.applyFilters();
1668
- }
1669
-
1670
- /**
1671
- * Remove filter manually
1672
- * @public
1673
- * @param {string} filter - Filter value
1674
- */
1675
- removeFilter(filter) {
1676
- this.afs.logger.debug(`Removing filter: ${filter}`);
1677
- this.activeFilters.delete(filter);
1678
-
1679
- // Update button states
1680
- this.filterButtons.forEach((value, button) => {
1681
- if (value === filter) {
1682
- button.classList.remove(this.afs.options.get("activeClass"));
1683
- }
1684
- });
1685
-
1686
- // Reset to all if no filters active
1687
- if (this.activeFilters.size === 0) {
1688
- this.resetFilters();
1689
- } else {
1690
- this.applyFilters();
1691
- }
1692
- }
1693
-
1694
- /**
1695
- * Set filter mode
1696
- * @public
1697
- * @param {string} mode - Filter mode ('AND' or 'OR')
1698
- */
1699
- setFilterMode(mode) {
1700
- this.afs.logger.debug(`Setting filter mode to: ${mode}`);
1701
- const validMode = mode.toUpperCase();
1702
- if (["AND", "OR"].includes(validMode)) {
1703
- this.afs.options.set("filterMode", validMode);
1704
- this.afs.logger.debug(`Set filter mode to: ${validMode}`);
1705
- this.applyFilters();
1706
- } else {
1707
- this.afs.logger.warn(`Invalid filter mode: ${mode}`);
1708
- }
1709
- }
1710
-
1711
- /**
1712
- * Get active filters
1713
- * @public
1714
- * @returns {Set} Active filters
1715
- */
1716
- getActiveFilters() {
1717
- return new Set(this.activeFilters);
1718
- }
1719
-
1720
- /**
1721
- * Get filter groups
1722
- * @public
1723
- * @returns {Map} Filter groups
1724
- */
1725
- getFilterGroups() {
1726
- return new Map(this.filterGroups);
1727
- }
1728
-
1729
- /**
1730
- * Add filter button dynamically
1731
- * @public
1732
- * @param {HTMLElement} button - Button element
1733
- * @param {string} filter - Filter value
1734
- */
1735
- addFilterButton(button, filter) {
1736
- if (!filter) {
1737
- this.afs.logger.warn("Filter value required for new filter button");
1738
- return;
1739
- }
1740
- this.filterButtons.set(button, filter);
1741
- this.bindFilterEvent(button);
1742
- this.afs.logger.debug(`Added filter button for: ${filter}`);
1743
- }
1744
-
1745
- /**
1746
- * Remove filter button
1747
- * @public
1748
- * @param {HTMLElement} button - Button to remove
1749
- */
1750
- removeFilter(filter) {
1751
- this.afs.logger.debug(`Removing filter: ${filter}`);
1752
- this.activeFilters.delete(filter);
1753
-
1754
- // Update button states
1755
- this.filterButtons.forEach((value, button) => {
1756
- if (value === filter) {
1757
- button.classList.remove(this.afs.options.get("activeClass"));
1758
- }
1759
- });
1760
-
1761
- // Reset to all if no filters active
1762
- if (this.activeFilters.size === 0) {
1763
- this.resetFilters();
1764
- } else {
1765
- this.applyFilters();
1766
- }
1767
- }
1768
-
1769
- /**
1770
- * Sort items with automatic order detection
1771
- * @public
1772
- * @param {string} key - The data attribute key to sort by
1773
- */
1774
- sortWithOrder(key) {
1775
- this.afs.logger.debug(`Sorting by ${key}`);
1776
- try {
1777
- const items = Array.from(this.afs.items);
1778
-
1779
- // Toggle sort order for the key
1780
- const currentOrder = this.sortOrders.get(key) || "asc";
1781
- const newOrder = currentOrder === "asc" ? "desc" : "asc";
1782
- this.sortOrders.set(key, newOrder);
1783
-
1784
- // Determine sort type from first item
1785
- const sortType = this.determineSortType(items[0], key);
1786
-
1787
- // Sort items
1788
- items.sort((a, b) => {
1789
- const valueA = this.getSortValue(a, key, sortType);
1790
- const valueB = this.getSortValue(b, key, sortType);
1791
- return this.compareValues(valueA, valueB, newOrder);
1792
- });
1793
-
1794
- // Reorder DOM elements
1795
- this.reorderItems(items);
1796
-
1797
- // Emit event
1798
- this.afs.emit("sort", {
1799
- key,
1800
- order: newOrder
1801
- });
1802
- this.afs.logger.info(`Sorted items by ${key} in ${newOrder} order`);
1803
- return newOrder; // Return the new order for UI updates
1804
- } catch (error) {
1805
- this.afs.logger.error("Sort error:", error);
1806
- return null;
1807
- }
1808
- }
1809
-
1810
- /**
1811
- * Shuffle items randomly
1812
- * @public
1813
- */
1814
- shuffle() {
1815
- this.afs.logger.debug("Shuffling items");
1816
- try {
1817
- const items = Array.from(this.afs.items);
1818
-
1819
- // Fisher-Yates shuffle algorithm
1820
- for (let i = items.length - 1; i > 0; i--) {
1821
- const j = Math.floor(Math.random() * (i + 1));
1822
- [items[i], items[j]] = [items[j], items[i]];
1823
- }
1824
-
1825
- // Use the existing reorderItems method to update the DOM
1826
- this.reorderItems(items);
1827
-
1828
- // Clear any existing sort orders as we've shuffled
1829
- this.sortOrders.clear();
1830
- this.afs.emit("shuffled", {
1831
- itemCount: items.length
1832
- });
1833
- this.afs.logger.debug("Items shuffled successfully");
1834
- } catch (error) {
1835
- this.afs.logger.error("Shuffle error:", error);
1836
- }
1837
- }
1838
-
1839
- /**
1840
- * Determine sort type from item value
1841
- * @private
1842
- * @param {HTMLElement} item - DOM element
1843
- * @param {string} key - Sort key
1844
- * @returns {string} Sort type ('number', 'date', or 'string')
1845
- */
1846
- determineSortType(item, key) {
1847
- this.afs.logger.debug(`Determining sort type for ${key}`);
1848
- const value = item.dataset[key];
1849
- if (!value) return "string";
1850
- if (!isNaN(value)) return "number";
1851
- if (/^\d{4}-\d{2}-\d{2}/.test(value)) return "date";
1852
- return "string";
1853
- }
1854
-
1855
- /**
1856
- * Get sort value from item
1857
- * @private
1858
- * @param {HTMLElement} item - DOM element
1859
- * @param {string} key - Sort key
1860
- * @param {string} type - Sort type
1861
- * @returns {any} Parsed value for sorting
1862
- */
1863
- getSortValue(item, key, type) {
1864
- const value = item.dataset[key];
1865
- switch (type) {
1866
- case "number":
1867
- return parseFloat(value) || 0;
1868
- case "date":
1869
- return new Date(value).getTime() || 0;
1870
- default:
1871
- return (value || "").toLowerCase();
1872
- }
1873
- }
1874
-
1875
- /**
1876
- * Compare two values for sorting
1877
- * @private
1878
- * @param {any} a - First value
1879
- * @param {any} b - Second value
1880
- * @param {string} order - Sort order ('asc' or 'desc')
1881
- * @returns {number} Comparison result
1882
- */
1883
- compareValues(a, b, order) {
1884
- // Handle null/undefined values
1885
- if (a === null || a === undefined) return order === "asc" ? 1 : -1;
1886
- if (b === null || b === undefined) return order === "asc" ? -1 : 1;
1887
-
1888
- // Compare values
1889
- const comparison = a < b ? -1 : a > b ? 1 : 0;
1890
- return order === "asc" ? comparison : -comparison;
1891
- }
1892
-
1893
- /**
1894
- * Reorder DOM elements
1895
- * @private
1896
- * @param {HTMLElement[]} items - Sorted items
1897
- */
1898
- reorderItems(items) {
1899
- const fragment = document.createDocumentFragment();
1900
- items.forEach(item => fragment.appendChild(item));
1901
- this.afs.container.appendChild(fragment);
1902
- }
1903
-
1904
- /**
1905
- * Get current sort order for key
1906
- * @public
1907
- * @param {string} key - Sort key
1908
- * @returns {string} Current sort order ('asc' or 'desc')
1909
- */
1910
- getCurrentSortOrder(key) {
1911
- return this.sortOrders.get(key) || "asc";
1912
- }
1913
-
1914
- /**
1915
- * Clear all sort orders
1916
- * @public
1917
- */
1918
- clearSortOrders() {
1919
- this.afs.logger.debug("Clearing all sort orders");
1920
- this.sortOrders.clear();
1921
- this.afs.emit("sortCleared");
1922
- }
1923
-
1924
- /**
1925
- * Clear all filters, url and search
1926
- * @public
1927
- */
1928
- clearAllFilters() {
1929
- this.afs.logger.debug("Clearing all filters and search");
1930
-
1931
- // Reset filters
1932
- this.activeFilters.clear();
1933
- this.activeFilters.add("*");
1934
-
1935
- // Update filter buttons
1936
- this.filterButtons.forEach((_, button) => {
1937
- button.classList.remove(this.afs.options.get("activeClass"));
1938
- });
1939
-
1940
- // Find and activate "all" button if exists
1941
- const allButton = this.findAllButton();
1942
- if (allButton) {
1943
- allButton.classList.add(this.afs.options.get("activeClass"));
1944
- }
1945
-
1946
- // Clear checkboxes
1947
- const checkboxes = document.querySelectorAll('input[type="checkbox"]');
1948
- checkboxes.forEach(checkbox => {
1949
- if (checkbox.classList.contains(this.afs.options.get("activeClass"))) {
1950
- checkbox.checked = false;
1951
- checkbox.classList.remove(this.afs.options.get("activeClass"));
1952
- }
1953
- });
1954
-
1955
- // Clear search
1956
- if (this.afs.search) {
1957
- this.afs.search.setValue("");
1958
- }
1959
-
1960
- // Clear filter groups and sort orders
1961
- this.filterGroups.clear();
1962
- this.sortOrders.clear();
1963
-
1964
- // Apply changes and ensure counter is updated
1965
- this.applyFilters();
1966
-
1967
- // Update URL if URLManager exists
1968
- if (this.afs.urlManager) {
1969
- this.afs.urlManager.updateURL();
1970
- }
1971
-
1972
- // Emit event
1973
- this.afs.emit("filtersCleared");
1974
- this.afs.logger.info("All filters cleared");
1975
- }
1976
-
1977
- /**
1978
- * Refresh the view
1979
- * @public
1980
- */
1981
- refresh() {
1982
- this.afs.logger.debug("Refreshing view");
1983
- this.applyFilters();
1984
- this.afs.updateCounter();
1985
- }
1986
- removeFilterButton(button) {
1987
- this.filterButtons.delete(button);
1988
- button.removeEventListener("click", this.handleFilterClick);
1989
- }
1990
-
1991
- /**
1992
- * Destroy instance
1993
- * @public
1994
- */
1995
- destroy() {
1996
- this.filterButtons.forEach((_, button) => {
1997
- this.removeFilterButton(button);
1998
- });
1999
- this.filterButtons.clear();
2000
- this.activeFilters.clear();
2001
- this.filterGroups.clear();
2002
- this.afs.logger.debug("Filter functionality destroyed");
2003
- }
2004
- }
2005
-
2006
- /**
2007
- * @fileoverview Utility functions for AFS
2008
- */
2009
-
2010
- /**
2011
- * Debounce function
2012
- * @param {Function} func - Function to debounce
2013
- * @param {number} wait - Delay in milliseconds
2014
- * @param {boolean} [immediate=false] - Execute immediately
2015
- * @returns {Function} Debounced function
2016
- */
2017
- function debounce(func, wait) {
2018
- let immediate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
2019
- let timeout;
2020
- return function executedFunction() {
2021
- for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
2022
- args[_key] = arguments[_key];
2023
- }
2024
- const context = this;
2025
- const later = () => {
2026
- timeout = null;
2027
- if (!immediate) func.apply(context, args);
2028
- };
2029
- const callNow = immediate && !timeout;
2030
- clearTimeout(timeout);
2031
- timeout = setTimeout(later, wait);
2032
- if (callNow) func.apply(context, args);
2033
- };
2034
- }
2035
-
2036
- /**
2037
- * @fileoverview Search functionality for AFS
2038
- */
2039
-
2040
- class Search {
2041
- /**
2042
- * @param {import('../AFS').AFS} afs - Main AFS instance
2043
- */
2044
- constructor(afs) {
2045
- this.afs = afs;
2046
- this.searchInput = null;
2047
- this.searchKeys = ['title']; // Default search keys
2048
- this.minSearchLength = 2;
2049
- this.highlightClass = 'afs-highlight';
2050
- this.setupSearch();
2051
- }
2052
-
2053
- /**
2054
- * Setup search functionality
2055
- * @private
2056
- */
2057
- setupSearch() {
2058
- const searchSelector = this.afs.options.get('searchInputSelector');
2059
- if (!searchSelector) return;
2060
- this.searchInput = document.querySelector(searchSelector);
2061
- if (!this.searchInput) {
2062
- this.afs.logger.warn(`Search input not found: ${searchSelector}`);
2063
- return;
2064
- }
2065
-
2066
- // Configure search
2067
- this.searchKeys = this.afs.options.get('searchKeys') || this.searchKeys;
2068
- this.minSearchLength = this.afs.options.get('minSearchLength') || this.minSearchLength;
2069
-
2070
- // Bind events
2071
- this.bindSearchEvents();
2072
- this.afs.logger.debug('Search functionality initialized');
2073
- }
2074
-
2075
- /**
2076
- * Bind search events
2077
- * @private
2078
- */
2079
- bindSearchEvents() {
2080
- if (!this.searchInput) return;
2081
-
2082
- // Create debounced search function
2083
- const debouncedSearch = debounce(e => {
2084
- this.search(e.target.value);
2085
- }, this.afs.options.get('debounceTime') || 300);
2086
-
2087
- // Bind input event
2088
- this.searchInput.addEventListener('input', debouncedSearch);
2089
-
2090
- // Bind clear event
2091
- this.searchInput.addEventListener('search', e => {
2092
- if (!e.target.value) {
2093
- this.clearSearch();
2094
- }
2095
- });
2096
-
2097
- // Handle Enter key
2098
- this.searchInput.addEventListener('keypress', e => {
2099
- if (e.key === 'Enter') {
2100
- e.preventDefault();
2101
- this.search(e.target.value);
2102
- }
2103
- });
2104
- }
2105
-
2106
- /**
2107
- * Perform search
2108
- * @public
2109
- * @param {string} query - Search query
2110
- */
2111
- search(query) {
2112
- this.afs.logger.debug('Performing search:', query);
2113
- const normalizedQuery = this.normalizeQuery(query);
2114
-
2115
- // Update state
2116
- this.afs.state.setState('search.query', normalizedQuery);
2117
- let matches = 0;
2118
-
2119
- // Special case for empty query
2120
- if (!normalizedQuery) {
2121
- this.clearSearch();
2122
- return;
2123
- }
2124
-
2125
- // Skip if query is too short
2126
- if (normalizedQuery.length < this.minSearchLength) {
2127
- this.afs.logger.debug('Search query too short');
2128
- return;
2129
- }
2130
- try {
2131
- // Create search regex
2132
- const regex = this.createSearchRegex(normalizedQuery);
2133
-
2134
- // Search through items
2135
- this.afs.items.forEach(item => {
2136
- const searchText = this.getItemSearchText(item);
2137
- const matchesSearch = regex.test(searchText);
2138
- if (matchesSearch) {
2139
- this.afs.showItem(item);
2140
- this.highlightMatches(item, regex);
2141
- matches++;
2142
- } else {
2143
- this.afs.hideItem(item);
2144
- this.removeHighlights(item);
2145
- }
2146
- });
2147
-
2148
- // Update URL and emit event
2149
- this.afs.urlManager.updateURL();
2150
- this.afs.emit('search', {
2151
- query: normalizedQuery,
2152
- matches,
2153
- total: this.afs.items.length
2154
- });
2155
- this.afs.logger.info(`Search complete. Found ${matches} matches`);
2156
- } catch (error) {
2157
- this.afs.logger.error('Search error:', error);
2158
- }
2159
-
2160
- // Update counter after animation
2161
- setTimeout(() => {
2162
- this.afs.updateCounter();
2163
- }, this.afs.options.get('animationDuration'));
2164
- }
2165
-
2166
- /**
2167
- * Normalize search query
2168
- * @private
2169
- * @param {string} query - Raw search query
2170
- * @returns {string} Normalized query
2171
- */
2172
- normalizeQuery(query) {
2173
- return query.toLowerCase().trim().replace(/\s+/g, ' ');
2174
- }
2175
-
2176
- /**
2177
- * Create search regex
2178
- * @private
2179
- * @param {string} query - Normalized search query
2180
- * @returns {RegExp} Search regex
2181
- */
2182
- createSearchRegex(query) {
2183
- // Escape special regex characters
2184
- const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2185
-
2186
- // Split into words for whole word matching
2187
- const words = escapedQuery.split(' ').filter(Boolean);
2188
-
2189
- // Create regex pattern
2190
- const pattern = words.map(word => `(?=.*\\b${word})`).join('');
2191
- return new RegExp(pattern, 'i');
2192
- }
2193
-
2194
- /**
2195
- * Get searchable text from item
2196
- * @private
2197
- * @param {HTMLElement} item - DOM element
2198
- * @returns {string} Searchable text
2199
- */
2200
- getItemSearchText(item) {
2201
- return this.searchKeys.map(key => item.dataset[key] || '').join(' ').toLowerCase();
2202
- }
2203
-
2204
- /**
2205
- * Highlight search matches
2206
- * @private
2207
- * @param {HTMLElement} item - DOM element
2208
- * @param {RegExp} regex - Search regex
2209
- */
2210
- highlightMatches(item, regex) {
2211
- if (!this.afs.options.get('highlightMatches')) return;
2212
- this.searchKeys.forEach(key => {
2213
- const target = item.querySelector(`[data-search-key="${key}"]`);
2214
- if (!target) return;
2215
- const text = target.textContent;
2216
- const words = this.afs.state.getState().search.query.split(' ');
2217
- let highlightedText = text;
2218
- words.forEach(word => {
2219
- if (!word) return;
2220
- const wordRegex = new RegExp(`(${word})`, 'gi');
2221
- highlightedText = highlightedText.replace(wordRegex, `<span class="${this.highlightClass}">$1</span>`);
2222
- });
2223
- target.innerHTML = highlightedText;
2224
- });
2225
- }
2226
-
2227
- /**
2228
- * Remove highlights
2229
- * @private
2230
- * @param {HTMLElement} item - DOM element
2231
- */
2232
- removeHighlights(item) {
2233
- if (!this.afs.options.get('highlightMatches')) return;
2234
- this.searchKeys.forEach(key => {
2235
- const target = item.querySelector(`[data-search-key="${key}"]`);
2236
- if (!target) return;
2237
- const highlights = target.querySelectorAll(`.${this.highlightClass}`);
2238
- highlights.forEach(highlight => {
2239
- const text = highlight.textContent;
2240
- highlight.replaceWith(text);
2241
- });
2242
- });
2243
- }
2244
-
2245
- /**
2246
- * Clear search
2247
- * @public
2248
- */
2249
- clearSearch() {
2250
- this.afs.logger.debug('Clearing search');
2251
-
2252
- // Clear input
2253
- if (this.searchInput) {
2254
- this.searchInput.value = '';
2255
- }
2256
-
2257
- // Clear state
2258
- this.afs.state.setState('search.query', '');
2259
-
2260
- // Show all items
2261
- this.afs.items.forEach(item => {
2262
- this.afs.showItem(item);
2263
- this.removeHighlights(item);
2264
- });
2265
-
2266
- // Update URL and emit event
2267
- this.afs.urlManager.updateURL();
2268
- this.afs.emit('searchCleared');
2269
-
2270
- // Update counter
2271
- setTimeout(() => {
2272
- this.afs.updateCounter();
2273
- }, this.afs.options.get('animationDuration'));
2274
- }
2275
-
2276
- /**
2277
- * Set search value
2278
- * @public
2279
- * @param {string} value - Search value
2280
- */
2281
- setValue(value) {
2282
- if (this.searchInput) {
2283
- this.searchInput.value = value;
2284
- }
2285
- this.search(value);
2286
- }
2287
-
2288
- /**
2289
- * Get current search value
2290
- * @public
2291
- * @returns {string} Current search value
2292
- */
2293
- getValue() {
2294
- return this.afs.state.getState().search.query;
2295
- }
2296
-
2297
- /**
2298
- * Update search configuration
2299
- * @public
2300
- * @param {Object} config - Search configuration
2301
- */
2302
- updateConfig() {
2303
- let {
2304
- searchKeys,
2305
- minSearchLength,
2306
- highlightClass,
2307
- debounceTime
2308
- } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
2309
- if (searchKeys) this.searchKeys = searchKeys;
2310
- if (minSearchLength) this.minSearchLength = minSearchLength;
2311
- if (highlightClass) this.highlightClass = highlightClass;
2312
- if (debounceTime) {
2313
- this.bindSearchEvents(); // Rebind with new debounce time
2314
- }
2315
- }
2316
-
2317
- /**
2318
- * Destroy search functionality
2319
- * @public
2320
- */
2321
- destroy() {
2322
- if (this.searchInput) {
2323
- this.searchInput.removeEventListener('input', this.debouncedSearch);
2324
- this.searchInput.removeEventListener('search', this.handleClear);
2325
- this.searchInput.removeEventListener('keypress', this.handleEnter);
2326
- }
2327
- this.clearSearch();
2328
- }
2329
- }
2330
-
2331
- /**
2332
- * @fileoverview Sort functionality for AFS
2333
- */
2334
-
2335
- class Sort {
2336
- /**
2337
- * @param {import('../AFS').AFS} afs - Main AFS instance
2338
- */
2339
- constructor(afs) {
2340
- this.afs = afs;
2341
- this.sortButtons = new Map();
2342
- this.setupSort();
2343
- }
2344
-
2345
- /**
2346
- * Setup sort functionality
2347
- * @private
2348
- */
2349
- setupSort() {
2350
- const sortSelector = this.afs.options.get("sortButtonSelector");
2351
- if (!sortSelector) return;
2352
-
2353
- // Find and setup sort buttons
2354
- document.querySelectorAll(sortSelector).forEach(button => {
2355
- const key = button.dataset.sortKey;
2356
- if (!key) {
2357
- this.afs.logger.warn("Sort button missing data-sort-key attribute:", button);
2358
- return;
2359
- }
2360
- this.sortButtons.set(button, {
2361
- key,
2362
- direction: button.dataset.sortDirection || "asc"
2363
- });
2364
- this.bindSortEvent(button);
2365
- });
2366
- this.afs.logger.debug("Sort functionality initialized");
2367
- }
2368
-
2369
- /**
2370
- * Bind sort event to button
2371
- * @private
2372
- * @param {HTMLElement} button - Sort button
2373
- */
2374
- bindSortEvent(button) {
2375
- this.afs.logger.debug("Binding sort event to button:", button);
2376
- button.addEventListener("click", () => {
2377
- const sortData = this.sortButtons.get(button);
2378
- if (!sortData) return;
2379
-
2380
- // Toggle direction
2381
- sortData.direction = sortData.direction === "asc" ? "desc" : "asc";
2382
- this.sortButtons.set(button, sortData);
2383
-
2384
- // Update button state
2385
- this.updateSortButtonState(button, sortData);
2386
-
2387
- // Perform sort
2388
- this.sort(sortData.key, sortData.direction);
2389
- });
2390
- }
2391
-
2392
- /**
2393
- * Update sort button visual state
2394
- * @private
2395
- * @param {HTMLElement} button - Sort button
2396
- * @param {Object} sortData - Sort data
2397
- */
2398
- updateSortButtonState(button, sortData) {
2399
- // Remove active class from all buttons
2400
- this.sortButtons.forEach((_, btn) => {
2401
- btn.classList.remove(this.afs.options.get("activeSortClass"));
2402
- });
2403
-
2404
- // Add active class to current button
2405
- button.classList.add(this.afs.options.get("activeSortClass"));
2406
-
2407
- // Update direction indicator
2408
- const indicator = button.querySelector(".sort-direction");
2409
- if (indicator) {
2410
- indicator.textContent = sortData.direction === "asc" ? "↑" : "↓";
2411
- }
2412
- }
2413
-
2414
- /**
2415
- * Sort items
2416
- * @public
2417
- * @param {string} key - Sort key
2418
- * @param {string} direction - Sort direction ('asc' or 'desc')
2419
- */
2420
- sort(key) {
2421
- let direction = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "asc";
2422
- this.afs.logger.debug(`Sorting by ${key} in ${direction} order`);
2423
- try {
2424
- // Update state
2425
- this.afs.state.setState("sort.current", {
2426
- key,
2427
- direction
2428
- });
2429
-
2430
- // Get all items as array
2431
- const items = Array.from(this.afs.items);
2432
-
2433
- // Determine sort type
2434
- const sortType = this.determineSortType(items[0], key);
2435
-
2436
- // Sort items
2437
- items.sort((a, b) => {
2438
- const valueA = this.getSortValue(a, key, sortType);
2439
- const valueB = this.getSortValue(b, key, sortType);
2440
- return this.compareValues(valueA, valueB, direction);
2441
- });
2442
-
2443
- // Reorder DOM elements
2444
- this.reorderItems(items);
2445
-
2446
- // Update URL and emit event
2447
- this.afs.urlManager.updateURL();
2448
- this.afs.emit("sort", {
2449
- key,
2450
- direction
2451
- });
2452
- this.afs.logger.info(`Sorted items by ${key} ${direction}`);
2453
- } catch (error) {
2454
- this.afs.logger.error("Sort error:", error);
2455
- }
2456
- }
2457
-
2458
- /**
2459
- * Determine sort type from first item
2460
- * @private
2461
- * @param {HTMLElement} item - First item
2462
- * @param {string} key - Sort key
2463
- * @returns {string} Sort type
2464
- */
2465
- determineSortType(item, key) {
2466
- const value = item.dataset[key];
2467
- if (!value) return "string";
2468
- if (!isNaN(value)) return "number";
2469
- if (/^\d{4}-\d{2}-\d{2}/.test(value)) return "date";
2470
- return "string";
2471
- }
2472
-
2473
- /**
2474
- * Get sort value from item
2475
- * @private
2476
- * @param {HTMLElement} item - DOM element
2477
- * @param {string} key - Sort key
2478
- * @param {string} type - Sort type
2479
- * @returns {any} Sort value
2480
- */
2481
- getSortValue(item, key, type) {
2482
- const value = item.dataset[key];
2483
- switch (type) {
2484
- case "number":
2485
- return parseFloat(value);
2486
- case "date":
2487
- return new Date(value).getTime();
2488
- default:
2489
- return value.toLowerCase();
2490
- }
2491
- }
2492
-
2493
- /**
2494
- * Compare two values
2495
- * @private
2496
- * @param {any} a - First value
2497
- * @param {any} b - Second value
2498
- * @param {string} direction - Sort direction
2499
- * @returns {number} Comparison result
2500
- */
2501
- compareValues(a, b, direction) {
2502
- const modifier = direction === "asc" ? 1 : -1;
2503
- if (a === b) return 0;
2504
- if (a === undefined || a === null) return 1;
2505
- if (b === undefined || b === null) return -1;
2506
- return a > b ? modifier : -modifier;
2507
- }
2508
-
2509
- /**
2510
- * Reorder DOM elements
2511
- * @private
2512
- * @param {HTMLElement[]} items - Sorted items
2513
- */
2514
- reorderItems(items) {
2515
- const container = this.afs.options.get("container");
2516
- const fragment = document.createDocumentFragment();
2517
- items.forEach(item => fragment.appendChild(item));
2518
- container.appendChild(fragment);
2519
- }
2520
- /**
2521
- * Sort with multiple criteria
2522
- * @public
2523
- * @param {Array<{key: string, direction: string}>} criteria - Sort criteria
2524
- */
2525
- sortMultiple(criteria) {
2526
- this.afs.logger.debug("Sorting by multiple criteria:", criteria);
2527
- try {
2528
- const items = Array.from(this.afs.items);
2529
- items.sort((a, b) => {
2530
- for (const {
2531
- key,
2532
- direction
2533
- } of criteria) {
2534
- const type = this.determineSortType(items[0], key);
2535
- const valueA = this.getSortValue(a, key, type);
2536
- const valueB = this.getSortValue(b, key, type);
2537
- const comparison = this.compareValues(valueA, valueB, direction);
2538
- if (comparison !== 0) return comparison;
2539
- }
2540
- return 0;
2541
- });
2542
- this.reorderItems(items);
2543
-
2544
- // Update state with primary sort
2545
- if (criteria.length > 0) {
2546
- this.afs.state.setState("sort.current", criteria[0]);
2547
- }
2548
- this.afs.urlManager.updateURL();
2549
- this.afs.emit("multiSort", {
2550
- criteria
2551
- });
2552
- } catch (error) {
2553
- this.afs.logger.error("Multiple sort error:", error);
2554
- }
2555
- }
2556
-
2557
- /**
2558
- * Sort with custom comparator
2559
- * @public
2560
- * @param {string} key - Sort key
2561
- * @param {Function} comparator - Custom comparison function
2562
- */
2563
- sortWithComparator(key, comparator) {
2564
- this.afs.logger.debug(`Sorting by ${key} with custom comparator`);
2565
- try {
2566
- const items = Array.from(this.afs.items);
2567
- items.sort((a, b) => {
2568
- const valueA = a.dataset[key];
2569
- const valueB = b.dataset[key];
2570
- return comparator(valueA, valueB);
2571
- });
2572
- this.reorderItems(items);
2573
- this.afs.emit("customSort", {
2574
- key,
2575
- comparator
2576
- });
2577
- } catch (error) {
2578
- this.afs.logger.error("Custom sort error:", error);
2579
- }
2580
- }
2581
-
2582
- /**
2583
- * Shuffle items randomly
2584
- * @public
2585
- */
2586
- shuffle() {
2587
- this.afs.logger.debug("Shuffling items");
2588
- try {
2589
- const items = Array.from(this.afs.items);
2590
-
2591
- // Fisher-Yates shuffle algorithm
2592
- for (let i = items.length - 1; i > 0; i--) {
2593
- const j = Math.floor(Math.random() * (i + 1));
2594
- [items[i], items[j]] = [items[j], items[i]];
2595
- }
2596
- this.reorderItems(items);
2597
-
2598
- // Clear current sort state
2599
- this.afs.state.setState("sort.current", null);
2600
-
2601
- // Update UI
2602
- this.sortButtons.forEach((_, button) => {
2603
- button.classList.remove(this.afs.options.get("activeSortClass"));
2604
- const indicator = button.querySelector(".sort-direction");
2605
- if (indicator) indicator.textContent = "";
2606
- });
2607
- this.afs.urlManager.updateURL();
2608
- this.afs.emit("shuffle");
2609
- } catch (error) {
2610
- this.afs.logger.error("Shuffle error:", error);
2611
- }
2612
- }
2613
-
2614
- /**
2615
- * Reset sort to default state
2616
- * @public
2617
- */
2618
- reset() {
2619
- this.afs.logger.debug("Resetting sort");
2620
-
2621
- // Clear sort state
2622
- this.afs.state.setState("sort.current", null);
2623
-
2624
- // Reset UI
2625
- this.sortButtons.forEach((_, button) => {
2626
- button.classList.remove(this.afs.options.get("activeSortClass"));
2627
- const indicator = button.querySelector(".sort-direction");
2628
- if (indicator) indicator.textContent = "";
2629
- });
2630
-
2631
- // Reset sort data
2632
- this.sortButtons.forEach((data, button) => {
2633
- data.direction = "asc";
2634
- this.sortButtons.set(button, data);
2635
- });
2636
- this.afs.urlManager.updateURL();
2637
- this.afs.emit("sortReset");
2638
- }
2639
-
2640
- /**
2641
- * Get current sort state
2642
- * @public
2643
- * @returns {Object|null} Current sort state
2644
- */
2645
- getCurrentSort() {
2646
- return this.afs.state.getState().sort.current;
2647
- }
2648
-
2649
- /**
2650
- * Add sort button dynamically
2651
- * @public
2652
- * @param {HTMLElement} button - Sort button element
2653
- * @param {string} key - Sort key
2654
- * @param {string} [direction='asc'] - Initial sort direction
2655
- */
2656
- addSortButton(button, key) {
2657
- let direction = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "asc";
2658
- if (!key) {
2659
- this.afs.logger.warn("Sort key required for new sort button");
2660
- return;
2661
- }
2662
- this.sortButtons.set(button, {
2663
- key,
2664
- direction
2665
- });
2666
- this.bindSortEvent(button);
2667
- this.afs.logger.debug(`Added sort button for ${key}`);
2668
- }
2669
-
2670
- /**
2671
- * Remove sort button
2672
- * @public
2673
- * @param {HTMLElement} button - Sort button to remove
2674
- */
2675
- removeSortButton(button) {
2676
- if (this.sortButtons.has(button)) {
2677
- button.removeEventListener("click", this.bindSortEvent);
2678
- this.sortButtons.delete(button);
2679
- this.afs.logger.debug("Removed sort button");
2680
- }
2681
- }
2682
-
2683
- /**
2684
- * Destroy sort functionality
2685
- * @public
2686
- */
2687
- destroy() {
2688
- this.sortButtons.forEach((_, button) => {
2689
- this.removeSortButton(button);
2690
- });
2691
- this.sortButtons.clear();
2692
- this.afs.logger.debug("Sort functionality destroyed");
2693
- }
2694
- }
2695
-
2696
- /**
2697
- * @fileoverview Pagination functionality for AFS
2698
- */
2699
- class Pagination {
2700
- /**
2701
- * @param {import('../AFS').AFS} afs - Main AFS instance
2702
- */
2703
- constructor(afs) {
2704
- this.afs = afs;
2705
- this.container = null;
2706
- this.animation = new Animation(afs);
2707
- this.options = this.afs.options.get("pagination");
2708
- this.setupPagination();
2709
- }
2710
-
2711
- /**
2712
- * Setup pagination
2713
- * @private
2714
- */
2715
- setupPagination() {
2716
- this.afs.logger.debug("Setting up pagination");
2717
- if (!this.afs.options.get('pagination.enabled')) return;
2718
- console.log('Pagination enabled');
2719
- this.container = document.createElement("div");
2720
- this.container.className = this.options.containerClass;
2721
- const itemsContainer = document.querySelector(this.afs.options.get("pagination.container"));
2722
- if (!itemsContainer) {
2723
- this.afs.logger.error("Items container not found.");
2724
- return;
2725
- }
2726
- itemsContainer.appendChild(this.container);
2727
-
2728
- // Initialize pagination state with defaults
2729
- this.afs.state.setState("pagination", {
2730
- currentPage: 1,
2731
- itemsPerPage: this.options.itemsPerPage,
2732
- totalPages: 0
2733
- });
2734
- this.bindEvents();
2735
- this.update();
2736
- this.afs.logger.debug("Pagination initialized");
2737
- }
2738
-
2739
- /**
2740
- * Bind pagination events
2741
- * @private
2742
- */
2743
- bindEvents() {
2744
- this.afs.on("filter", () => this.update());
2745
- this.afs.on("search", () => this.update());
2746
- this.afs.on("sort", () => this.update());
2747
- this.container.addEventListener("click", e => {
2748
- const button = e.target.closest("button");
2749
- if (!button) return;
2750
- const page = button.dataset.page;
2751
- if (page) {
2752
- this.goToPage(parseInt(page, 10));
2753
- }
2754
- });
2755
- }
2756
-
2757
- /**
2758
- * Update pagination
2759
- * @public
2760
- */
2761
- update() {
2762
- const visibleItems = Array.from(this.afs.state.getState().items.visible);
2763
- const itemsPerPage = this.afs.state.getState().pagination.itemsPerPage;
2764
- const totalPages = Math.max(1, Math.ceil(visibleItems.length / itemsPerPage));
2765
-
2766
- // Update state
2767
- const currentState = this.afs.state.getState().pagination;
2768
- let currentPage = currentState.currentPage;
2769
-
2770
- // Adjust current page if it's beyond the total pages
2771
- if (currentPage > totalPages) {
2772
- currentPage = totalPages;
2773
- }
2774
-
2775
- // Update pagination state
2776
- this.afs.state.setState("pagination", {
2777
- ...currentState,
2778
- currentPage,
2779
- totalPages
2780
- });
2781
-
2782
- // Update visibility before rendering pagination controls
2783
- this.updateVisibility(visibleItems);
2784
- this.renderPagination();
2785
- this.afs.urlManager.updateURL();
2786
- this.afs.emit("pagination", {
2787
- currentPage,
2788
- totalPages,
2789
- itemsPerPage,
2790
- visibleItems: visibleItems.length
2791
- });
2792
- }
2793
-
2794
- /**
2795
- * Update items visibility based on current page
2796
- * @private
2797
- */
2798
- updateVisibility(visibleItems) {
2799
- const {
2800
- currentPage,
2801
- itemsPerPage
2802
- } = this.afs.state.getState().pagination;
2803
- const startIndex = (currentPage - 1) * itemsPerPage;
2804
- const endIndex = startIndex + itemsPerPage;
2805
-
2806
- // First hide all items
2807
- this.afs.items.forEach(item => {
2808
- item.style.display = "none";
2809
- item.classList.add(this.afs.options.get("hiddenClass"));
2810
- });
2811
-
2812
- // Then show only the items for the current page
2813
- const itemsToShow = visibleItems.slice(startIndex, endIndex);
2814
-
2815
- // Ensure we're not trying to display non-existent items
2816
- if (itemsToShow.length === 0 && visibleItems.length > 0) {
2817
- // If we have no items to show but we do have visible items,
2818
- // we're probably on an invalid page - go to page 1
2819
- this.goToPage(1);
2820
- return;
2821
- }
2822
-
2823
- // Show items with animation
2824
- requestAnimationFrame(() => {
2825
- itemsToShow.forEach(item => {
2826
- // Remove hidden class and restore display
2827
- item.style.display = "";
2828
- item.classList.remove(this.afs.options.get("hiddenClass"));
2829
-
2830
- // Apply show animation in the next frame
2831
- requestAnimationFrame(() => {
2832
- this.animation.applyShowAnimation(item, this.options.animationType || "fade");
2833
- });
2834
- });
2835
- });
2836
- }
2837
-
2838
- /**
2839
- * Render pagination controls
2840
- * @private
2841
- */
2842
- renderPagination() {
2843
- const {
2844
- currentPage,
2845
- totalPages
2846
- } = this.afs.state.getState().pagination;
2847
- this.container.innerHTML = "";
2848
- if (totalPages <= 1) {
2849
- this.container.style.display = "none";
2850
- return;
2851
- }
2852
- this.container.style.display = "flex";
2853
- const controls = this.createPaginationControls(currentPage, totalPages);
2854
- this.container.appendChild(controls);
2855
- }
2856
-
2857
- /**
2858
- * Create pagination controls
2859
- * @private
2860
- * @param {number} currentPage - Current page
2861
- * @param {number} totalPages - Total pages
2862
- * @returns {DocumentFragment} Pagination controls
2863
- */
2864
- createPaginationControls(currentPage, totalPages) {
2865
- const fragment = document.createDocumentFragment();
2866
- if (this.options.showPrevNext) {
2867
- const prevButton = this.createPageButton("‹", currentPage - 1, {
2868
- disabled: currentPage === 1,
2869
- class: "afs-pagination-prev"
2870
- });
2871
- fragment.appendChild(prevButton);
2872
- }
2873
- fragment.appendChild(this.createPageButton("1", 1, {
2874
- active: currentPage === 1
2875
- }));
2876
- const range = this.calculatePageRange(currentPage, totalPages);
2877
- if (range.start > 2) fragment.appendChild(this.createEllipsis());
2878
- for (let i = range.start; i <= range.end; i++) {
2879
- if (i === 1 || i === totalPages) continue;
2880
- fragment.appendChild(this.createPageButton(i.toString(), i, {
2881
- active: currentPage === i
2882
- }));
2883
- }
2884
- if (range.end < totalPages - 1) fragment.appendChild(this.createEllipsis());
2885
- if (totalPages > 1) fragment.appendChild(this.createPageButton(totalPages.toString(), totalPages, {
2886
- active: currentPage === totalPages
2887
- }));
2888
- if (this.options.showPrevNext) {
2889
- const nextButton = this.createPageButton("›", currentPage + 1, {
2890
- disabled: currentPage === totalPages,
2891
- class: "afs-pagination-next"
2892
- });
2893
- fragment.appendChild(nextButton);
2894
- }
2895
- return fragment;
2896
- }
2897
-
2898
- /**
2899
- * Create page button
2900
- * @private
2901
- */
2902
- createPageButton(text, page) {
2903
- let {
2904
- active = false,
2905
- disabled = false,
2906
- class: className = ""
2907
- } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
2908
- const button = document.createElement("button");
2909
- button.textContent = text;
2910
- button.dataset.page = page;
2911
- button.classList.add(this.options.pageButtonClass || "afs-page-button");
2912
- if (className) button.classList.add(className);
2913
- if (active) button.classList.add(this.options.activePageClass || "afs-page-active");
2914
- if (disabled) button.disabled = true;
2915
- return button;
2916
- }
2917
- createEllipsis() {
2918
- const span = document.createElement("span");
2919
- span.textContent = "...";
2920
- span.classList.add("afs-pagination-ellipsis");
2921
- return span;
2922
- }
2923
- calculatePageRange(currentPage, totalPages) {
2924
- const maxButtons = this.options.maxButtons || 7;
2925
- const sideButtons = Math.floor((maxButtons - 3) / 2);
2926
- let start = Math.max(2, currentPage - sideButtons);
2927
- let end = Math.min(totalPages - 1, start + maxButtons - 3);
2928
- if (end - start < maxButtons - 3) start = Math.max(2, end - (maxButtons - 3));
2929
- return {
2930
- start,
2931
- end
2932
- };
2933
- }
2934
-
2935
- /**
2936
- * Go to specific page
2937
- * @public
2938
- */
2939
- goToPage(page) {
2940
- const state = this.afs.state.getState().pagination;
2941
- const targetPage = Math.max(1, Math.min(page, state.totalPages));
2942
- if (targetPage === state.currentPage) return;
2943
-
2944
- // Update state
2945
- this.afs.state.setState("pagination.currentPage", targetPage);
2946
-
2947
- // Force a reflow before updating
2948
- this.afs.container.offsetHeight;
2949
-
2950
- // Update visibility and controls
2951
- this.update();
2952
-
2953
- // Scroll to top if enabled
2954
- if (this.options.scrollToTop) {
2955
- setTimeout(() => this.scrollToTop(), 100);
2956
- }
2957
-
2958
- // Emit page change event
2959
- this.afs.emit("pageChanged", {
2960
- previousPage: state.currentPage,
2961
- currentPage: targetPage,
2962
- totalPages: state.totalPages
2963
- });
2964
- }
2965
- scrollToTop() {
2966
- const container = document.querySelector(this.afs.options.get("pagination.container"));
2967
- if (!container) {
2968
- this.afs.logger.warn("Scroll container not found.");
2969
- return;
2970
- }
2971
- window.scrollTo({
2972
- top: container.offsetTop - this.options.scrollOffset,
2973
- behavior: "smooth"
2974
- });
2975
- }
2976
-
2977
- /**
2978
- * Set pagination mode
2979
- * @public
2980
- */
2981
- setPaginationMode(enabled) {
2982
- this.afs.logger.debug(`Setting pagination mode to: ${enabled}`);
2983
-
2984
- // Update options
2985
- this.afs.options.set('pagination.enabled', enabled);
2986
- if (enabled) {
2987
- // Enable pagination
2988
- this.setupPagination();
2989
- } else {
2990
- // Disable pagination and show all items
2991
- this.container.remove();
2992
- this.showAllItems();
2993
- }
2994
-
2995
- // Emit event
2996
- this.afs.emit('paginationModeChanged', {
2997
- enabled
2998
- });
2999
- }
3000
-
3001
- /**
3002
- * Show all items (for infinite scroll mode)
3003
- * @private
3004
- */
3005
- showAllItems() {
3006
- const visibleItems = Array.from(this.afs.state.getState().items.visible);
3007
- requestAnimationFrame(() => {
3008
- visibleItems.forEach(item => {
3009
- item.style.display = '';
3010
- item.classList.remove(this.afs.options.get('hiddenClass'));
3011
- requestAnimationFrame(() => {
3012
- this.animation.applyShowAnimation(item, this.options.animationType || 'fade');
3013
- });
3014
- });
3015
- });
3016
- }
3017
- }
3018
-
3019
- /**
3020
- * @fileoverview URL state management for AFS
3021
- */
3022
-
3023
- class URLManager {
3024
- /**
3025
- * @param {import('../AFS').AFS} afs - Main AFS instance
3026
- */
3027
- constructor(afs) {
3028
- this.afs = afs;
3029
- this.defaultParams = new URLSearchParams();
3030
- this.setupPopStateHandler();
3031
-
3032
- // Load URL state after a small delay to ensure all features are ready
3033
- setTimeout(() => {
3034
- this.loadFromURL();
3035
- }, 0);
3036
- }
3037
-
3038
- /**
3039
- * Setup history popstate handler
3040
- * @private
3041
- */
3042
- setupPopStateHandler() {
3043
- window.addEventListener('popstate', () => {
3044
- this.loadFromURL();
3045
- });
3046
- }
3047
-
3048
- /**
3049
- * Update URL with current filter state
3050
- * @public
3051
- */
3052
- /**
3053
- * Update URL with current filter state
3054
- * @public
3055
- */
3056
- updateURL() {
3057
- this.afs.logger.debug("Updating URL state");
3058
- const params = new URLSearchParams();
3059
- const state = this.afs.state.getState(); // Get current state of filters
3060
-
3061
- // Get active filters directly from the Filter instance
3062
- const activeFilters = this.afs.filter.getActiveFilters();
3063
-
3064
- // Update the state object with the active filters
3065
- state.filters.current = activeFilters;
3066
-
3067
- // Add filters to URL
3068
- this.addFiltersToURL(params, state);
3069
-
3070
- // Add ranges (if applicable) to URL
3071
- this.addRangesToURL(params, state);
3072
-
3073
- // Add search query to URL
3074
- this.addSearchToURL(params, state);
3075
-
3076
- // Add sort state to URL
3077
- this.addSortToURL(params, state);
3078
-
3079
- // Add pagination to URL
3080
- this.addPaginationToURL(params, state);
3081
-
3082
- // Push the updated URL
3083
- this.pushState(params); // Push the new URL state to the browser
3084
- }
3085
-
3086
- /**
3087
- * Add filters to URL parameters
3088
- * @private
3089
- * @param {URLSearchParams} params
3090
- * @param {Object} state
3091
- */
3092
- addFiltersToURL(params, state) {
3093
- const filters = state.filters;
3094
-
3095
- // Skip if only default filter is active
3096
- if (filters.current.size === 0 || filters.current.size === 1 && filters.current.has('*')) {
3097
- return;
3098
- }
3099
-
3100
- // Group filters by type
3101
- const filtersByType = {};
3102
- for (const filter of filters.current) {
3103
- if (filter !== '*') {
3104
- const [type, value] = filter.split(':');
3105
- if (!filtersByType[type]) {
3106
- filtersByType[type] = new Set();
3107
- }
3108
- filtersByType[type].add(value);
3109
- }
3110
- }
3111
-
3112
- // Add filters to params
3113
- Object.entries(filtersByType).forEach(_ref => {
3114
- let [type, values] = _ref;
3115
- params.set(type, Array.from(values).join(','));
3116
- });
3117
-
3118
- // Add filter mode if not default
3119
- if (filters.mode !== 'OR') {
3120
- params.set('filterMode', filters.mode.toLowerCase());
3121
- }
3122
-
3123
- // Add group mode if groups exist and mode isn't default
3124
- if (filters.groups.size > 0 && filters.groupMode !== 'OR') {
3125
- params.set('groupMode', filters.groupMode.toLowerCase());
3126
- }
3127
-
3128
- // Add filter groups if they exist
3129
- filters.groups.forEach((group, groupId) => {
3130
- params.set(`group_${groupId}`, Array.from(group.filters).join(','));
3131
- if (group.operator !== 'OR') {
3132
- params.set(`groupOp_${groupId}`, group.operator.toLowerCase());
3133
- }
3134
- });
3135
- }
3136
-
3137
- /**
3138
- * Add range filters to URL parameters
3139
- * @private
3140
- * @param {URLSearchParams} params
3141
- * @param {Object} state
3142
- */
3143
- addRangesToURL(params, state) {
3144
- // Handle numeric ranges
3145
- state.filters.ranges.forEach((range, key) => {
3146
- const {
3147
- currentMin,
3148
- currentMax
3149
- } = range;
3150
- if (currentMin !== range.min || currentMax !== range.max) {
3151
- params.set(`range_${key}`, `${currentMin},${currentMax}`);
3152
- }
3153
- });
3154
-
3155
- // Handle date ranges
3156
- state.filters.dateRanges.forEach((range, key) => {
3157
- const {
3158
- start,
3159
- end
3160
- } = range;
3161
- params.set(`dateRange_${key}`, `${start.toISOString()},${end.toISOString()}`);
3162
- });
3163
- }
3164
-
3165
- /**
3166
- * Add search parameters to URL
3167
- * @private
3168
- * @param {URLSearchParams} params
3169
- * @param {Object} state
3170
- */
3171
- addSearchToURL(params, state) {
3172
- if (state.search.query) {
3173
- params.set('search', state.search.query);
3174
- }
3175
- }
3176
-
3177
- /**
3178
- * Add sort parameters to URL
3179
- * @private
3180
- * @param {URLSearchParams} params
3181
- * @param {Object} state
3182
- */
3183
- addSortToURL(params, state) {
3184
- if (state.sort.current) {
3185
- const {
3186
- key,
3187
- direction
3188
- } = state.sort.current;
3189
- params.set('sort', `${key},${direction}`);
3190
- }
3191
- }
3192
-
3193
- /**
3194
- * Add pagination parameters to URL
3195
- * @private
3196
- * @param {URLSearchParams} params
3197
- * @param {Object} state
3198
- */
3199
- addPaginationToURL(params, state) {
3200
- const {
3201
- currentPage,
3202
- itemsPerPage
3203
- } = state.pagination;
3204
- if (!this.afs.options.get('pagination.enabled')) {
3205
- return;
3206
- }
3207
- if (currentPage > 1) {
3208
- params.set('page', currentPage.toString());
3209
- }
3210
- if (itemsPerPage !== this.afs.options.get('pagination.itemsPerPage')) {
3211
- params.set('perPage', itemsPerPage.toString());
3212
- }
3213
- }
3214
-
3215
- /**
3216
- * Update browser URL
3217
- * @private
3218
- * @param {URLSearchParams} params
3219
- */
3220
- pushState(params) {
3221
- const queryString = params.toString();
3222
- const newURL = `${window.location.pathname}${queryString ? '?' + queryString : ''}`;
3223
-
3224
- // Only update if URL actually changed
3225
- if (newURL !== window.location.href) {
3226
- window.history.pushState({}, '', newURL);
3227
- this.afs.logger.debug('URL updated:', newURL);
3228
- }
3229
- }
3230
-
3231
- /**
3232
- * Load filter state from URL
3233
- * @public
3234
- */
3235
- loadFromURL() {
3236
- this.afs.logger.debug('Loading state from URL');
3237
- const params = new URLSearchParams(window.location.search);
3238
- try {
3239
- // Clear existing state first
3240
- if (this.afs.filter) {
3241
- this.afs.filter.clearAllFilters();
3242
- }
3243
-
3244
- // Process filter mode first
3245
- const filterMode = params.get('filterMode');
3246
- if (filterMode && this.afs.filter) {
3247
- this.afs.filter.setFilterMode(filterMode.toUpperCase());
3248
- }
3249
-
3250
- // Process regular filters
3251
- const filterParams = Array.from(params.entries()).filter(_ref2 => {
3252
- let [key] = _ref2;
3253
- return this.isRegularFilter(key);
3254
- });
3255
- if (filterParams.length > 0 && this.afs.filter) {
3256
- // Remove default '*' filter
3257
- this.afs.filter.activeFilters.clear();
3258
-
3259
- // Add filters and update button states
3260
- filterParams.forEach(_ref3 => {
3261
- let [type, values] = _ref3;
3262
- values.split(',').forEach(value => {
3263
- const filter = `${type}:${value}`;
3264
- this.afs.filter.activeFilters.add(filter);
3265
-
3266
- // Update button UI
3267
- this.afs.filter.filterButtons.forEach((buttonFilter, button) => {
3268
- if (buttonFilter === filter) {
3269
- button.classList.add(this.afs.options.get('activeClass'));
3270
- } else if (buttonFilter === '*') {
3271
- button.classList.remove(this.afs.options.get('activeClass'));
3272
- }
3273
- });
3274
- });
3275
- });
3276
- }
3277
-
3278
- // Apply filters before processing other parameters
3279
- if (this.afs.filter) {
3280
- this.afs.filter.applyFilters();
3281
- }
3282
-
3283
- // Process search
3284
- const searchQuery = params.get('search');
3285
- if (searchQuery && this.afs.search) {
3286
- this.afs.search.setValue(searchQuery);
3287
- }
3288
-
3289
- // Process sort
3290
- const sortParam = params.get('sort');
3291
- if (sortParam && this.afs.sort) {
3292
- const [key, direction] = sortParam.split(',');
3293
- this.afs.sort.sort(key, direction);
3294
- }
3295
-
3296
- // Process pagination
3297
- const page = parseInt(params.get('page'));
3298
- if (!isNaN(page) && this.afs.pagination) {
3299
- this.afs.pagination.goToPage(page);
3300
- }
3301
- this.afs.emit('urlStateLoaded', {
3302
- params: Object.fromEntries(params)
3303
- });
3304
- this.afs.logger.info('State loaded from URL');
3305
- } catch (error) {
3306
- this.afs.logger.error('Error loading state from URL:', error);
3307
- // Reset to default state on error
3308
- if (this.afs.filter) {
3309
- this.afs.filter.clearAllFilters();
3310
- }
3311
- }
3312
- }
3313
-
3314
- /**
3315
- * Process filters from URL parameters
3316
- * @private
3317
- * @param {URLSearchParams} params
3318
- */
3319
- processFiltersFromURL(params) {
3320
- const state = this.afs.state.getState();
3321
- let hasFilters = false;
3322
-
3323
- // Process filter mode
3324
- const filterMode = params.get('filterMode');
3325
- if (filterMode) {
3326
- state.filters.mode = filterMode.toUpperCase();
3327
- }
3328
-
3329
- // Process group mode
3330
- const groupMode = params.get('groupMode');
3331
- if (groupMode) {
3332
- state.filters.groupMode = groupMode.toUpperCase();
3333
- }
3334
-
3335
- // Process regular filters
3336
- for (const [type, values] of params.entries()) {
3337
- if (this.isRegularFilter(type)) {
3338
- values.split(',').filter(Boolean).forEach(value => {
3339
- hasFilters = true;
3340
- state.filters.current.add(`${type}:${value}`);
3341
- });
3342
- }
3343
- }
3344
-
3345
- // Process filter groups
3346
- for (const [key, value] of params.entries()) {
3347
- if (key.startsWith('group_')) {
3348
- const groupId = key.replace('group_', '');
3349
- const operator = params.get(`groupOp_${groupId}`)?.toUpperCase() || 'OR';
3350
- state.filters.groups.set(groupId, {
3351
- filters: new Set(value.split(',')),
3352
- operator
3353
- });
3354
- }
3355
- }
3356
-
3357
- // Set default if no filters
3358
- if (!hasFilters && state.filters.groups.size === 0) {
3359
- state.filters.current.add('*');
3360
- }
3361
- }
3362
-
3363
- /**
3364
- * Process range filters from URL parameters
3365
- * @private
3366
- * @param {URLSearchParams} params
3367
- */
3368
- processRangesFromURL(params) {
3369
- const state = this.afs.state.getState();
3370
-
3371
- // Process numeric ranges
3372
- for (const [key, value] of params.entries()) {
3373
- if (key.startsWith('range_')) {
3374
- const rangeKey = key.replace('range_', '');
3375
- const [min, max] = value.split(',').map(Number);
3376
- state.filters.ranges.set(rangeKey, {
3377
- currentMin: min,
3378
- currentMax: max
3379
- });
3380
- }
3381
- }
3382
-
3383
- // Process date ranges
3384
- for (const [key, value] of params.entries()) {
3385
- if (key.startsWith('dateRange_')) {
3386
- const rangeKey = key.replace('dateRange_', '');
3387
- const [start, end] = value.split(',').map(str => new Date(str));
3388
- state.filters.dateRanges.set(rangeKey, {
3389
- start,
3390
- end
3391
- });
3392
- }
3393
- }
3394
- }
3395
-
3396
- /**
3397
- * Process search from URL parameters
3398
- * @private
3399
- * @param {URLSearchParams} params
3400
- */
3401
- processSearchFromURL(params) {
3402
- const searchQuery = params.get('search') || '';
3403
- this.afs.state.setState('search.query', searchQuery);
3404
- if (this.afs.options.get('searchInput')) {
3405
- this.afs.options.get('searchInput').value = searchQuery;
3406
- }
3407
- }
3408
-
3409
- /**
3410
- * Process sort from URL parameters
3411
- * @private
3412
- * @param {URLSearchParams} params
3413
- */
3414
- processSortFromURL(params) {
3415
- const sortParam = params.get('sort');
3416
- if (sortParam) {
3417
- const [key, direction] = sortParam.split(',');
3418
- this.afs.state.setState('sort.current', {
3419
- key,
3420
- direction
3421
- });
3422
- }
3423
- }
3424
-
3425
- /**
3426
- * Process pagination from URL parameters
3427
- * @private
3428
- * @param {URLSearchParams} params
3429
- */
3430
- processPaginationFromURL(params) {
3431
- const page = parseInt(params.get('page')) || 1;
3432
- const perPage = parseInt(params.get('perPage')) || this.afs.options.get('pagination.itemsPerPage');
3433
- this.afs.state.setState('pagination', {
3434
- currentPage: page,
3435
- itemsPerPage: perPage
3436
- });
3437
- }
3438
-
3439
- /**
3440
- * Check if parameter is a regular filter
3441
- * @private
3442
- * @param {string} param - Parameter name
3443
- * @returns {boolean}
3444
- */
3445
- isRegularFilter(param) {
3446
- const excludedParams = ['search', 'sort', 'page', 'perPage', 'filterMode', 'groupMode'];
3447
- return !excludedParams.includes(param) && !param.startsWith('group_') && !param.startsWith('groupOp_') && !param.startsWith('range_') && !param.startsWith('dateRange_');
3448
- }
3449
-
3450
- /**
3451
- * Clear URL parameters
3452
- * @public
3453
- */
3454
- clearURL() {
3455
- window.history.pushState({}, '', window.location.pathname);
3456
- this.afs.state.reset();
3457
- if (this.afs.filter) {
3458
- this.afs.filter.clearAllFilters();
3459
- }
3460
- }
3461
-
3462
- /**
3463
- * Get current URL parameters
3464
- * @public
3465
- * @returns {URLSearchParams}
3466
- */
3467
- getURLParams() {
3468
- return new URLSearchParams(window.location.search);
3469
- }
3470
-
3471
- /**
3472
- * Check if URL has parameters
3473
- * @public
3474
- * @returns {boolean}
3475
- */
3476
- hasParams() {
3477
- return window.location.search.length > 1;
3478
- }
3479
-
3480
- /**
3481
- * Get parameter value
3482
- * @public
3483
- * @param {string} param - Parameter name
3484
- * @returns {string|null}
3485
- */
3486
- getParam(param) {
3487
- return new URLSearchParams(window.location.search).get(param);
3488
- }
3489
- }
3490
-
3491
- /**
3492
- * @fileoverview Range filter implementation for AFS
3493
- */
3494
-
3495
- class RangeFilter {
3496
- constructor(afs) {
3497
- this.afs = afs;
3498
- this.activeRanges = new Map();
3499
- this.options = this.afs.options.get("slider");
3500
- if (!this.afs.styleManager) {
3501
- this.afs.styleManager = new StyleManager(this.afs.options);
3502
- }
3503
-
3504
- // Apply styles immediately
3505
- this.afs.styleManager.applyStyles();
3506
- }
3507
-
3508
- /**
3509
- * @typedef {Object} RangeOptions
3510
- * @property {string} key - Data attribute key
3511
- * @property {string} type - Type of range ('number' or 'date')
3512
- * @property {HTMLElement} container - Container element
3513
- * @property {number|string} [min] - Minimum value
3514
- * @property {number|string} [max] - Maximum value
3515
- * @property {number} [step] - Step value
3516
- */
3517
-
3518
- /**
3519
- * Add range slider
3520
- * @param {RangeOptions} options - Range slider options
3521
- */
3522
- addRangeSlider(_ref) {
3523
- let {
3524
- key,
3525
- type,
3526
- container,
3527
- min,
3528
- max,
3529
- step = 1,
3530
- ui
3531
- } = _ref;
3532
- this.afs.logger.debug(`Adding range slider for ${key}`);
3533
- if (!container) {
3534
- this.afs.logger.error("Container element required for range slider");
3535
- return;
3536
- }
3537
-
3538
- // Calculate min/max if not provided
3539
- const values = this.calculateMinMax(key, type);
3540
- min = min ?? values.min;
3541
- max = max ?? values.max;
3542
-
3543
- // Get global UI options and merge with slider-specific options
3544
- const globalUiOptions = this.afs.options.get("styles.slider.ui") || {
3545
- showHistogram: false,
3546
- bins: 10
3547
- };
3548
- const sliderUiOptions = {
3549
- ...globalUiOptions,
3550
- ...ui // Override with slider-specific options if provided
3551
- };
3552
-
3553
- // Only calculate histogram data if enabled
3554
- const histogramData = sliderUiOptions.showHistogram ? this.calculateHistogramData(key, sliderUiOptions.bins) : {
3555
- counts: [],
3556
- binEdges: [],
3557
- max: 0
3558
- };
3559
-
3560
- // Create slider elements
3561
- const elements = this.createSliderElements(histogramData, sliderUiOptions);
3562
- const state = this.initializeState(min, max, step, type);
3563
-
3564
- // Store histogram data and UI options in state
3565
- state.ui = sliderUiOptions;
3566
- if (sliderUiOptions.showHistogram) {
3567
- state.histogram = histogramData;
3568
- }
3569
-
3570
- // Add elements to container
3571
- this.appendElements(container, elements);
3572
-
3573
- // Setup event handlers
3574
- this.setupEventHandlers(elements, state, key);
3575
-
3576
- // Store state
3577
- this.activeRanges.set(key, {
3578
- state,
3579
- elements
3580
- });
3581
-
3582
- // Initial update
3583
- this.updateSliderUI(key);
3584
-
3585
- // Setup histogram highlight updates only if enabled
3586
- if (sliderUiOptions.showHistogram) {
3587
- this.setupHistogramHighlight(elements, state, histogramData.binEdges);
3588
- }
3589
- this.afs.logger.info(`Range slider added for ${key}`);
3590
- }
3591
- /**
3592
- * Calculate min and max values from items
3593
- * @private
3594
- */
3595
- calculateMinMax(key, type) {
3596
- const values = Array.from(this.afs.items).map(item => {
3597
- const value = item.dataset[key];
3598
- return type === "date" ? new Date(value).getTime() : parseFloat(value);
3599
- }).filter(value => !isNaN(value));
3600
- return {
3601
- min: Math.min(...values),
3602
- max: Math.max(...values)
3603
- };
3604
- }
3605
-
3606
- /**
3607
- * Create slider DOM elements
3608
- * @private
3609
- * @param {Object} histogramData - Histogram data
3610
- * @param {Object} sliderUiOptions - UI options for this slider
3611
- */
3612
- createSliderElements(histogramData, sliderUiOptions) {
3613
- const styles = this.afs.options.get("styles") || this.afs.styleManager.defaultStyles;
3614
- const colors = styles.colors || this.afs.styleManager.defaultStyles.colors;
3615
- const sliderOptions = this.afs.options.get("slider") || {};
3616
- const container = document.createElement("div");
3617
- container.className = "afs-range-container";
3618
- const slider = document.createElement("div");
3619
- slider.className = sliderOptions.containerClass;
3620
- const track = document.createElement("div");
3621
- track.className = sliderOptions.trackClass;
3622
-
3623
- // Only add histogram if enabled in the slider-specific options
3624
- if (sliderUiOptions?.showHistogram && histogramData?.counts?.length > 0) {
3625
- const histogram = this.createHistogramBars(histogramData, colors);
3626
- slider.appendChild(histogram);
3627
- }
3628
- const selectedRange = document.createElement("div");
3629
- selectedRange.className = sliderOptions.selectedClass;
3630
- const minThumb = document.createElement("div");
3631
- minThumb.className = sliderOptions.thumbClass;
3632
- const maxThumb = document.createElement("div");
3633
- maxThumb.className = sliderOptions.thumbClass;
3634
- const minValue = document.createElement("div");
3635
- minValue.className = sliderOptions.valueClass;
3636
- const maxValue = document.createElement("div");
3637
- maxValue.className = sliderOptions.valueClass;
3638
-
3639
- // Build the slider
3640
- slider.appendChild(track);
3641
- slider.appendChild(selectedRange);
3642
- slider.appendChild(minThumb);
3643
- slider.appendChild(maxThumb);
3644
- slider.appendChild(minValue);
3645
- slider.appendChild(maxValue);
3646
- container.appendChild(slider);
3647
- return {
3648
- container,
3649
- slider,
3650
- track,
3651
- selectedRange,
3652
- minThumb,
3653
- maxThumb,
3654
- minValue,
3655
- maxValue
3656
- };
3657
- }
3658
-
3659
- // Add helper method for creating histogram
3660
- createHistogram(data, colors) {
3661
- const histogram = document.createElement("div");
3662
- histogram.className = "afs-histogram";
3663
-
3664
- // Create histogram bars
3665
- data.forEach((value, index) => {
3666
- const bar = document.createElement("div");
3667
- bar.className = "afs-histogram-bar";
3668
- bar.style.height = `${value}%`;
3669
- bar.style.backgroundColor = colors.histogram;
3670
- histogram.appendChild(bar);
3671
- });
3672
- return histogram;
3673
- }
3674
-
3675
- /**
3676
- * Calculate histogram data from items
3677
- * @private
3678
- * @param {string} key - Data attribute key (e.g., 'price')
3679
- * @param {number} [bins=10] - Number of bins for histogram
3680
- * @returns {Object} Histogram data and metadata
3681
- */
3682
- calculateHistogramData(key) {
3683
- let bins = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 10;
3684
- try {
3685
- const values = Array.from(this.afs.items).map(item => parseFloat(item.dataset[key])).filter(value => !isNaN(value));
3686
- if (values.length === 0) {
3687
- return {
3688
- counts: [],
3689
- binEdges: [],
3690
- max: 0
3691
- };
3692
- }
3693
- const min = Math.min(...values);
3694
- const max = Math.max(...values);
3695
- const binWidth = (max - min) / bins;
3696
- const counts = new Array(bins).fill(0);
3697
- const binEdges = new Array(bins + 1);
3698
-
3699
- // Calculate bin edges
3700
- for (let i = 0; i <= bins; i++) {
3701
- binEdges[i] = min + i * binWidth;
3702
- }
3703
-
3704
- // Count values in each bin
3705
- values.forEach(value => {
3706
- if (value === max) {
3707
- counts[counts.length - 1]++;
3708
- return;
3709
- }
3710
- const binIndex = Math.floor((value - min) / binWidth);
3711
- counts[binIndex]++;
3712
- });
3713
-
3714
- // Normalize heights to be more subtle
3715
- const maxCount = Math.max(...counts);
3716
- const normalizedCounts = counts.map(count => Math.max(20, Math.round(count / maxCount * 100)) // Minimum height of 20%
3717
- );
3718
- return {
3719
- counts: normalizedCounts,
3720
- binEdges,
3721
- max: maxCount,
3722
- min,
3723
- max
3724
- };
3725
- } catch (error) {
3726
- this.afs.logger.error("Error calculating histogram:", error);
3727
- return {
3728
- counts: [],
3729
- binEdges: [],
3730
- max: 0
3731
- };
3732
- }
3733
- }
3734
-
3735
- /**
3736
- * Setup histogram highlight on range changes
3737
- * @private
3738
- */
3739
- setupHistogramHighlight(elements, state, binEdges) {
3740
- const bars = elements.slider.querySelectorAll(".afs-histogram-bar");
3741
- const updateHistogram = () => {
3742
- const minVal = state.currentMin;
3743
- const maxVal = state.currentMax;
3744
- bars.forEach((bar, index) => {
3745
- const binStart = binEdges[index];
3746
- const binEnd = binEdges[index + 1];
3747
-
3748
- // Highlight bars within the selected range
3749
- if (binStart >= minVal && binEnd <= maxVal) {
3750
- bar.classList.add("active");
3751
- } else {
3752
- bar.classList.remove("active");
3753
- }
3754
- });
3755
- };
3756
-
3757
- // Update histogram on range changes using AFS instance
3758
- this.afs.on("rangeFilter", () => updateHistogram());
3759
-
3760
- // Initial update
3761
- updateHistogram();
3762
- }
3763
-
3764
- /**
3765
- * Create histogram bars
3766
- * @private
3767
- */
3768
- createHistogramBars(histogramData, colors) {
3769
- const {
3770
- counts
3771
- } = histogramData;
3772
- const histogram = document.createElement("div");
3773
- histogram.className = "afs-histogram";
3774
- counts.forEach(height => {
3775
- const bar = document.createElement("div");
3776
- bar.className = "afs-histogram-bar";
3777
- bar.style.height = `${height}%`;
3778
- histogram.appendChild(bar);
3779
- });
3780
- return histogram;
3781
- }
3782
-
3783
- /**
3784
- * Initialize slider state
3785
- * @private
3786
- */
3787
- initializeState(min, max, step, type) {
3788
- return {
3789
- min,
3790
- max,
3791
- currentMin: min,
3792
- currentMax: max,
3793
- step,
3794
- type,
3795
- isDragging: false
3796
- };
3797
- }
3798
-
3799
- /**
3800
- * Append elements to container
3801
- * @private
3802
- */
3803
- appendElements(container, elements) {
3804
- const {
3805
- slider,
3806
- track,
3807
- selectedRange,
3808
- minThumb,
3809
- maxThumb,
3810
- minValue,
3811
- maxValue
3812
- } = elements;
3813
- slider.appendChild(track);
3814
- slider.appendChild(selectedRange);
3815
- slider.appendChild(minThumb);
3816
- slider.appendChild(maxThumb);
3817
- slider.appendChild(minValue);
3818
- slider.appendChild(maxValue);
3819
- container.appendChild(slider);
3820
- }
3821
-
3822
- /**
3823
- * Setup event handlers for slider
3824
- * @private
3825
- */
3826
- setupEventHandlers(elements, state, key) {
3827
- const {
3828
- minThumb,
3829
- maxThumb
3830
- } = elements;
3831
- const handleStart = isMin => e => {
3832
- e.preventDefault(); // Prevent scrolling while dragging on mobile
3833
- state.isDragging = true;
3834
-
3835
- // Get the correct event coordinates whether mouse or touch
3836
- const getEventXY = event => {
3837
- return event.touches ? event.touches[0] : event;
3838
- };
3839
- const moveHandler = moveEvent => {
3840
- const evt = getEventXY(moveEvent);
3841
- this.createMoveHandler(elements, state, key, isMin)(evt);
3842
- };
3843
- const stopHandler = () => {
3844
- state.isDragging = false;
3845
-
3846
- // Remove both mouse and touch event listeners
3847
- window.removeEventListener('mousemove', moveHandler);
3848
- window.removeEventListener('mouseup', stopHandler);
3849
- window.removeEventListener('touchmove', moveHandler);
3850
- window.removeEventListener('touchend', stopHandler);
3851
- window.removeEventListener('touchcancel', stopHandler);
3852
- this.applyFilter(key);
3853
- };
3854
-
3855
- // Add both mouse and touch event listeners
3856
- window.addEventListener('mousemove', moveHandler);
3857
- window.addEventListener('mouseup', stopHandler);
3858
- window.addEventListener('touchmove', moveHandler, {
3859
- passive: false
3860
- });
3861
- window.addEventListener('touchend', stopHandler);
3862
- window.addEventListener('touchcancel', stopHandler);
3863
- };
3864
-
3865
- // Add both mouse and touch event listeners to thumbs
3866
- minThumb.addEventListener('mousedown', handleStart(true));
3867
- minThumb.addEventListener('touchstart', handleStart(true), {
3868
- passive: false
3869
- });
3870
- maxThumb.addEventListener('mousedown', handleStart(false));
3871
- maxThumb.addEventListener('touchstart', handleStart(false), {
3872
- passive: false
3873
- });
3874
- }
3875
-
3876
- /**
3877
- * Update slider UI
3878
- * @private
3879
- */
3880
- updateSliderUI(key) {
3881
- const {
3882
- state,
3883
- elements
3884
- } = this.activeRanges.get(key);
3885
- const {
3886
- minThumb,
3887
- maxThumb,
3888
- selectedRange,
3889
- minValue,
3890
- maxValue
3891
- } = elements;
3892
-
3893
- // Calculate positions with padding consideration
3894
- const range = state.max - state.min;
3895
- const minPos = (state.currentMin - state.min) / range * 100;
3896
- const maxPos = (state.currentMax - state.min) / range * 100;
3897
-
3898
- // Ensure thumbs stay within bounds
3899
- const clampedMinPos = Math.max(0, Math.min(minPos, 100));
3900
- const clampedMaxPos = Math.max(0, Math.min(maxPos, 100));
3901
-
3902
- // Position thumbs
3903
- minThumb.style.left = `${clampedMinPos}%`;
3904
- maxThumb.style.left = `${clampedMaxPos}%`;
3905
-
3906
- // Position selected range
3907
- selectedRange.style.left = `${clampedMinPos}%`;
3908
- selectedRange.style.width = `${clampedMaxPos - clampedMinPos}%`;
3909
-
3910
- // Format values
3911
- const formatValue = state.type === "date" ? value => new Date(value).toLocaleDateString() : value => value.toFixed(2);
3912
-
3913
- // Update value labels
3914
- minValue.textContent = formatValue(state.currentMin);
3915
- maxValue.textContent = formatValue(state.currentMax);
3916
-
3917
- // Position value labels considering bounds
3918
- minValue.style.left = `${clampedMinPos}%`;
3919
- maxValue.style.left = `${clampedMaxPos}%`;
3920
-
3921
- // Handle edge cases for value label positioning
3922
- if (clampedMinPos < 5) {
3923
- minValue.style.transform = "translateX(0)";
3924
- } else if (clampedMinPos > 95) {
3925
- minValue.style.transform = "translateX(-100%)";
3926
- } else {
3927
- minValue.style.transform = "translateX(-50%)";
3928
- }
3929
- if (clampedMaxPos < 5) {
3930
- maxValue.style.transform = "translateX(0)";
3931
- } else if (clampedMaxPos > 95) {
3932
- maxValue.style.transform = "translateX(-100%)";
3933
- } else {
3934
- maxValue.style.transform = "translateX(-50%)";
3935
- }
3936
- }
3937
-
3938
- // Also update the createMoveHandler to respect the padding
3939
- createMoveHandler(elements, state, key, isMin) {
3940
- this.afs.logger.debug(`Creating move handler for ${key}`);
3941
- const {
3942
- track
3943
- } = elements;
3944
- const PADDING = 5;
3945
- return debounce(e => {
3946
- const clientX = e.touches ? e.touches[0].clientX : e.clientX;
3947
- const rect = track.getBoundingClientRect();
3948
- const totalWidth = rect.width;
3949
- const paddingPixels = PADDING / 100 * totalWidth;
3950
-
3951
- // Calculate percent with padding consideration
3952
- const rawPercent = (clientX - rect.left - paddingPixels) / (totalWidth - 2 * paddingPixels);
3953
- const percent = Math.min(Math.max(0, rawPercent), 1);
3954
-
3955
- // Calculate value considering the full range
3956
- const value = state.min + (state.max - state.min) * percent;
3957
- const stepped = Math.round(value / state.step) * state.step;
3958
- if (isMin) {
3959
- state.currentMin = Math.min(stepped, state.currentMax);
3960
- } else {
3961
- state.currentMax = Math.max(stepped, state.currentMin);
3962
- }
3963
- this.updateSliderUI(key);
3964
- }, 16); // ~60fps
3965
- }
3966
-
3967
- /**
3968
- * Apply range filter
3969
- * @private
3970
- */
3971
- applyFilter(key) {
3972
- this.afs.logger.debug(`Applying range filter for ${key}`);
3973
- const {
3974
- state
3975
- } = this.activeRanges.get(key);
3976
- this.afs.items.forEach(item => {
3977
- const value = state.type === "date" ? new Date(item.dataset[key]).getTime() : parseFloat(item.dataset[key]);
3978
- if (value >= state.currentMin && value <= state.currentMax) {
3979
- this.afs.showItem(item);
3980
- } else {
3981
- this.afs.hideItem(item);
3982
- }
3983
- });
3984
- this.afs.updateCounter();
3985
- this.afs.urlManager.updateURL();
3986
- this.afs.emit("rangeFilter", {
3987
- key,
3988
- min: state.currentMin,
3989
- max: state.currentMax
3990
- });
3991
- }
3992
-
3993
- /**
3994
- * Get current range values
3995
- * @param {string} key - Range key
3996
- * @returns {Object} Current range values
3997
- */
3998
- getRangeValues(key) {
3999
- const range = this.activeRanges.get(key);
4000
- if (!range) return null;
4001
- return {
4002
- min: range.state.currentMin,
4003
- max: range.state.currentMax,
4004
- type: range.state.type
4005
- };
4006
- }
4007
-
4008
- /**
4009
- * Set range values
4010
- * @param {string} key - Range key
4011
- * @param {number} min - Minimum value
4012
- * @param {number} max - Maximum value
4013
- */
4014
- setRangeValues(key, min, max) {
4015
- const range = this.activeRanges.get(key);
4016
- if (!range) return;
4017
- range.state.currentMin = min;
4018
- range.state.currentMax = max;
4019
- this.updateSliderUI(key);
4020
- this.applyFilter(key);
4021
- }
4022
-
4023
- /**
4024
- * Remove range slider
4025
- * @param {string} key - Range key
4026
- */
4027
- removeRangeSlider(key) {
4028
- const range = this.activeRanges.get(key);
4029
- if (!range) return;
4030
- range.elements.slider.remove();
4031
- this.activeRanges.delete(key);
4032
- this.afs.logger.info(`Range slider removed for ${key}`);
4033
- }
4034
- }
4035
-
4036
- /**
4037
- * @fileoverview Date filter implementation for AFS
4038
- */
4039
-
4040
- class DateFilter {
4041
- constructor(afs) {
4042
- this.afs = afs;
4043
- this.activeDateRanges = new Map();
4044
- this.defaultFormat = this.afs.options.get('dateFormat') || 'YYYY-MM-DD';
4045
- }
4046
-
4047
- /**
4048
- * @typedef {Object} DateRangeOptions
4049
- * @property {string} key - Data attribute key
4050
- * @property {HTMLElement} container - Container element
4051
- * @property {Date} [minDate] - Minimum date
4052
- * @property {Date} [maxDate] - Maximum date
4053
- * @property {string} [format] - Date format (default: YYYY-MM-DD)
4054
- */
4055
-
4056
- /**
4057
- * Add date range filter
4058
- * @param {DateRangeOptions} options - Date range options
4059
- */
4060
- addDateRange(_ref) {
4061
- let {
4062
- key,
4063
- container,
4064
- minDate,
4065
- maxDate,
4066
- format = this.defaultFormat
4067
- } = _ref;
4068
- this.afs.logger.debug(`Adding date range for ${key}`);
4069
- if (!container) {
4070
- this.afs.logger.error('Container element required for date range');
4071
- return;
4072
- }
4073
-
4074
- // Calculate min/max dates if not provided
4075
- const dates = this.calculateMinMaxDates(key);
4076
- minDate = minDate ?? dates.min;
4077
- maxDate = maxDate ?? dates.max;
4078
-
4079
- // Create date picker elements
4080
- const elements = this.createDateElements();
4081
- const state = this.initializeState(minDate, maxDate, format);
4082
-
4083
- // Add elements to container
4084
- this.appendElements(container, elements);
4085
-
4086
- // Setup event handlers
4087
- this.setupEventHandlers(elements, state, key);
4088
-
4089
- // Store state
4090
- this.activeDateRanges.set(key, {
4091
- state,
4092
- elements
4093
- });
4094
-
4095
- // Initial update
4096
- this.updateDateUI(key);
4097
- this.afs.logger.info(`Date range added for ${key}`);
4098
- }
4099
-
4100
- /**
4101
- * Calculate min and max dates from items
4102
- * @private
4103
- */
4104
- calculateMinMaxDates(key) {
4105
- try {
4106
- const validDates = Array.from(this.afs.items).map(item => {
4107
- if (!item || !item.dataset || !item.dataset[key]) {
4108
- return null;
4109
- }
4110
- const date = new Date(item.dataset[key]);
4111
- return isNaN(date.getTime()) ? null : date;
4112
- }).filter(date => date !== null);
4113
- if (validDates.length === 0) {
4114
- // Return default date range if no valid dates found
4115
- const today = new Date();
4116
- return {
4117
- min: new Date(today.getFullYear(), 0, 1),
4118
- // January 1st of current year
4119
- max: new Date(today.getFullYear(), 11, 31) // December 31st of current year
4120
- };
4121
- }
4122
- return {
4123
- min: new Date(Math.min(...validDates)),
4124
- max: new Date(Math.max(...validDates))
4125
- };
4126
- } catch (error) {
4127
- this.afs.logger.error('Error calculating date range:', error);
4128
- // Return default date range on error
4129
- const today = new Date();
4130
- return {
4131
- min: new Date(today.getFullYear(), 0, 1),
4132
- max: new Date(today.getFullYear(), 11, 31)
4133
- };
4134
- }
4135
- }
4136
-
4137
- /**
4138
- * Create date picker elements
4139
- * @private
4140
- */
4141
- createDateElements() {
4142
- this.afs.logger.debug('Creating date picker elements');
4143
- const container = document.createElement('div');
4144
- container.className = 'afs-date-range-container';
4145
- const startContainer = document.createElement('div');
4146
- startContainer.className = 'afs-date-input-wrapper';
4147
- const startLabel = document.createElement('label');
4148
- startLabel.textContent = 'Start Date';
4149
- const startInput = document.createElement('input');
4150
- startInput.type = 'date';
4151
- startInput.className = 'afs-date-input start-date';
4152
- const endContainer = document.createElement('div');
4153
- endContainer.className = 'afs-date-input-wrapper';
4154
- const endLabel = document.createElement('label');
4155
- endLabel.textContent = 'End Date';
4156
- const endInput = document.createElement('input');
4157
- endInput.type = 'date';
4158
- endInput.className = 'afs-date-input end-date';
4159
- startContainer.appendChild(startLabel);
4160
- startContainer.appendChild(startInput);
4161
- endContainer.appendChild(endLabel);
4162
- endContainer.appendChild(endInput);
4163
- container.appendChild(startContainer);
4164
- container.appendChild(endContainer);
4165
- return {
4166
- container,
4167
- startInput,
4168
- endInput
4169
- };
4170
- }
4171
-
4172
- /**
4173
- * Initialize date filter state
4174
- * @private
4175
- */
4176
- initializeState(minDate, maxDate, format) {
4177
- return {
4178
- minDate,
4179
- maxDate,
4180
- currentStartDate: minDate,
4181
- currentEndDate: maxDate,
4182
- format
4183
- };
4184
- }
4185
-
4186
- /**
4187
- * Append elements to container
4188
- * @private
4189
- */
4190
- appendElements(container, elements) {
4191
- container.appendChild(elements.container);
4192
- }
4193
-
4194
- /**
4195
- * Setup event handlers
4196
- * @private
4197
- */
4198
- setupEventHandlers(elements, state, key) {
4199
- this.afs.logger.debug(`Setting up event handlers for date range ${key}`);
4200
- const {
4201
- startInput,
4202
- endInput
4203
- } = elements;
4204
- const handleDateChange = debounce(() => {
4205
- const startDate = new Date(startInput.value);
4206
- const endDate = new Date(endInput.value);
4207
- if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
4208
- state.currentStartDate = startDate;
4209
- state.currentEndDate = endDate;
4210
- this.applyDateFilter(key);
4211
- }
4212
- }, 300);
4213
- startInput.addEventListener('change', handleDateChange);
4214
- endInput.addEventListener('change', handleDateChange);
4215
- }
4216
-
4217
- /**
4218
- * Update date picker UI
4219
- * @private
4220
- */
4221
- updateDateUI(key) {
4222
- try {
4223
- const {
4224
- state,
4225
- elements
4226
- } = this.activeDateRanges.get(key);
4227
- const {
4228
- startInput,
4229
- endInput
4230
- } = elements;
4231
-
4232
- // Format dates for input
4233
- const formatDate = date => {
4234
- try {
4235
- const d = new Date(date);
4236
- if (isNaN(d.getTime())) {
4237
- throw new Error('Invalid date');
4238
- }
4239
- return d.toISOString().split('T')[0];
4240
- } catch (error) {
4241
- this.afs.logger.error('Error formatting date:', error);
4242
- return '';
4243
- }
4244
- };
4245
-
4246
- // Set min/max constraints
4247
- startInput.min = formatDate(state.minDate);
4248
- startInput.max = formatDate(state.maxDate);
4249
- endInput.min = formatDate(state.minDate);
4250
- endInput.max = formatDate(state.maxDate);
4251
-
4252
- // Set current values
4253
- startInput.value = formatDate(state.currentStartDate);
4254
- endInput.value = formatDate(state.currentEndDate);
4255
- } catch (error) {
4256
- this.afs.logger.error('Error updating date UI:', error);
4257
- }
4258
- }
4259
-
4260
- /**
4261
- * Apply date filter
4262
- * @private
4263
- */
4264
- applyDateFilter(key) {
4265
- this.afs.logger.info(`Applying date filter for ${key}`);
4266
- const {
4267
- state
4268
- } = this.activeDateRanges.get(key);
4269
- this.afs.items.forEach(item => {
4270
- try {
4271
- // Check if item and dataset exist
4272
- if (!item || !item.dataset || !item.dataset[key]) {
4273
- this.afs.hideItem(item);
4274
- return;
4275
- }
4276
- const itemDate = new Date(item.dataset[key]);
4277
-
4278
- // Check if date is valid
4279
- if (isNaN(itemDate.getTime())) {
4280
- this.afs.hideItem(item);
4281
- return;
4282
- }
4283
-
4284
- // Set time to midnight for consistent comparison
4285
- const startDate = new Date(state.currentStartDate);
4286
- startDate.setHours(0, 0, 0, 0);
4287
- const endDate = new Date(state.currentEndDate);
4288
- endDate.setHours(23, 59, 59, 999);
4289
- const compareDate = new Date(itemDate);
4290
- compareDate.setHours(0, 0, 0, 0);
4291
- if (compareDate >= startDate && compareDate <= endDate) {
4292
- this.afs.showItem(item);
4293
- } else {
4294
- this.afs.hideItem(item);
4295
- }
4296
- } catch (error) {
4297
- this.afs.logger.error('Error filtering item by date:', error);
4298
- this.afs.hideItem(item);
4299
- }
4300
- });
4301
- this.afs.updateCounter();
4302
- this.afs.urlManager.updateURL();
4303
- this.afs.emit('dateFilter', {
4304
- key,
4305
- startDate: state.currentStartDate,
4306
- endDate: state.currentEndDate
4307
- });
4308
- }
4309
-
4310
- /**
4311
- * Get current date range
4312
- * @param {string} key - Date range key
4313
- * @returns {Object} Current date range
4314
- */
4315
- getDateRange(key) {
4316
- const range = this.activeDateRanges.get(key);
4317
- if (!range) return null;
4318
- return {
4319
- startDate: range.state.currentStartDate,
4320
- endDate: range.state.currentEndDate
4321
- };
4322
- }
4323
-
4324
- /**
4325
- * Set date range
4326
- * @param {string} key - Date range key
4327
- * @param {Date} startDate - Start date
4328
- * @param {Date} endDate - End date
4329
- */
4330
- setDateRange(key, startDate, endDate) {
4331
- const range = this.activeDateRanges.get(key);
4332
- if (!range) return;
4333
- range.state.currentStartDate = startDate;
4334
- range.state.currentEndDate = endDate;
4335
- this.updateDateUI(key);
4336
- this.applyDateFilter(key);
4337
- }
4338
-
4339
- /**
4340
- * Remove date range
4341
- * @param {string} key - Date range key
4342
- */
4343
- removeDateRange(key) {
4344
- const range = this.activeDateRanges.get(key);
4345
- if (!range) return;
4346
- range.elements.container.remove();
4347
- this.activeDateRanges.delete(key);
4348
- this.afs.logger.info(`Date range removed for ${key}`);
4349
- }
4350
- }
4351
-
4352
- /**
4353
- * @fileoverview Advanced Filter System - Main Class
4354
- */
4355
-
4356
- // Version
4357
- const VERSION = '1.0.8';
4358
- class AFS extends EventEmitter {
4359
- /**
4360
- * @param {Object} options - Configuration options
4361
- */
4362
- constructor() {
4363
- let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
4364
- super();
4365
- this.initializeCore(options);
4366
- }
4367
-
4368
- /**
4369
- * Initialize core components
4370
- * @private
4371
- */
4372
- initializeCore(options) {
4373
- try {
4374
- this.options = new Options(options);
4375
- this.logger = new Logger(this.options.get("debug") || false, this.options.get("logLevel") || "info");
4376
- this.state = new State();
4377
- this.styleManager = new StyleManager(this.options);
4378
- this.initializeDOM();
4379
- this.initializeFeatures();
4380
- this.setupLifecycle();
4381
- } catch (error) {
4382
- console.error("AFS initialization error:", error);
4383
- throw error;
4384
- }
4385
- }
4386
-
4387
- /**
4388
- * Initialize DOM elements
4389
- * @private
4390
- */
4391
- initializeDOM() {
4392
- this.logger.debug("Initializing DOM elements");
4393
- this.container = document.querySelector(this.options.get("containerSelector"));
4394
- if (!this.container) {
4395
- throw new Error(`Container not found: ${this.options.get("containerSelector")}`);
4396
- }
4397
- this.items = this.container.querySelectorAll(this.options.get("itemSelector"));
4398
- if (this.items.length === 0) {
4399
- this.logger.warn("No items found in container");
4400
- }
4401
- this.state.setState("items.total", this.items.length);
4402
- this.state.setState("items.visible", new Set(this.items));
4403
- }
4404
-
4405
- /**
4406
- * Initialize features
4407
- * @private
4408
- */
4409
- initializeFeatures() {
4410
- this.logger.debug("Initializing features");
4411
-
4412
- // Then initialize other features
4413
- this.filter = new Filter(this);
4414
- this.search = new Search(this);
4415
- this.sort = new Sort(this);
4416
- this.rangeFilter = new RangeFilter(this);
4417
- this.urlManager = new URLManager(this);
4418
- this.dateFilter = new DateFilter(this);
4419
- this.pagination = new Pagination(this);
4420
-
4421
- // Apply styles
4422
- this.styleManager.applyStyles();
4423
-
4424
- // Load initial state from URL
4425
- this.urlManager.loadFromURL();
4426
- }
4427
-
4428
- /**
4429
- * Setup lifecycle events
4430
- * @private
4431
- */
4432
- setupLifecycle() {
4433
- if (this.options.get("responsive")) {
4434
- window.addEventListener("resize", this.handleResize.bind(this));
4435
- }
4436
- if (this.options.get("preserveState")) {
4437
- document.addEventListener("visibilitychange", this.handleVisibilityChange.bind(this));
4438
- }
4439
- if (this.options.get("observeDOM")) {
4440
- this.setupMutationObserver();
4441
- }
4442
- this.emit("initialized", {
4443
- itemCount: this.items.length,
4444
- options: this.options.export()
4445
- });
4446
- }
4447
-
4448
- // Item Management Methods
4449
- /**
4450
- * Show item with animation
4451
- * @public
4452
- * @param {HTMLElement} item - Item to show
4453
- */
4454
- showItem(item) {
4455
- // Update state first
4456
- const visibleItems = this.state.getState().items.visible;
4457
- visibleItems.add(item);
4458
- this.state.setState("items.visible", visibleItems);
4459
-
4460
- // Remove hidden class but keep opacity 0 initially
4461
- item.classList.remove(this.options.get("hiddenClass"));
4462
-
4463
- // Set initial animation state
4464
- item.style.opacity = "0";
4465
- item.style.transform = "scale(0.95)";
4466
- item.style.display = ""; // Ensure item is not display: none
4467
-
4468
- // Force reflow before starting animation
4469
- item.offsetHeight;
4470
-
4471
- // Add transition class if not present
4472
- const transitionClass = this.options.get("transitionClass") || "afs-transition";
4473
- if (!item.classList.contains(transitionClass)) {
4474
- item.classList.add(transitionClass);
4475
- }
4476
-
4477
- // Start animation in next frame
4478
- requestAnimationFrame(() => {
4479
- requestAnimationFrame(() => {
4480
- // Double RAF for reliable animation
4481
- item.style.opacity = "1";
4482
- item.style.transform = "scale(1)";
4483
- });
4484
- });
4485
-
4486
- // Clean up after animation
4487
- const duration = this.options.get("animationDuration") || 300;
4488
- setTimeout(() => {
4489
- // Only clean up if item is still meant to be visible
4490
- if (visibleItems.has(item)) {
4491
- item.style.transform = "";
4492
- item.style.opacity = "";
4493
- }
4494
- }, duration);
4495
- }
4496
-
4497
- /**
4498
- * Hide item with animation
4499
- * @public
4500
- * @param {HTMLElement} item - Item to hide
4501
- */
4502
- hideItem(item) {
4503
- // Update state first
4504
- const visibleItems = this.state.getState().items.visible;
4505
- visibleItems.delete(item);
4506
- this.state.setState("items.visible", visibleItems);
4507
-
4508
- // Add transition class if not present
4509
- const transitionClass = this.options.get("transitionClass") || "afs-transition";
4510
- if (!item.classList.contains(transitionClass)) {
4511
- item.classList.add(transitionClass);
4512
- }
4513
-
4514
- // Start hide animation
4515
- requestAnimationFrame(() => {
4516
- item.style.opacity = "0";
4517
- item.style.transform = "scale(0.95)";
4518
- });
4519
-
4520
- // Add hidden class and clean up after animation
4521
- const duration = this.options.get("animationDuration") || 300;
4522
- setTimeout(() => {
4523
- // Only hide if the item is still meant to be hidden
4524
- if (!visibleItems.has(item)) {
4525
- item.classList.add(this.options.get("hiddenClass"));
4526
- // Clean up styles
4527
- item.style.transform = "";
4528
- item.style.opacity = "";
4529
- }
4530
- }, duration);
4531
- }
4532
-
4533
- /**
4534
- * Add new items
4535
- * @public
4536
- */
4537
- addItems(newItems) {
4538
- const items = Array.isArray(newItems) ? newItems : [newItems];
4539
- const fragment = document.createDocumentFragment();
4540
- items.forEach(item => {
4541
- fragment.appendChild(item);
4542
- this.state.getState().items.visible.add(item);
4543
- });
4544
- this.container.appendChild(fragment);
4545
- this.items = this.container.querySelectorAll(this.options.get("itemSelector"));
4546
- this.filter.applyFilters();
4547
- }
4548
-
4549
- /**
4550
- * Remove items
4551
- * @public
4552
- */
4553
- removeItems(itemsToRemove) {
4554
- const items = Array.isArray(itemsToRemove) ? itemsToRemove : [itemsToRemove];
4555
- const visibleItems = this.state.getState().items.visible;
4556
- items.forEach(item => {
4557
- visibleItems.delete(item);
4558
- item.remove();
4559
- });
4560
- this.items = this.container.querySelectorAll(this.options.get("itemSelector"));
4561
- this.updateCounter();
4562
- }
4563
-
4564
- // State Management Methods
4565
- /**
4566
- * Save current state
4567
- * @public
4568
- */
4569
- saveState() {
4570
- if (!this.options.get("preserveState")) return;
4571
- const state = {
4572
- filters: Array.from(this.filter.getActiveFilters()),
4573
- search: this.search.getValue(),
4574
- sort: this.sort.getCurrentSort(),
4575
- pagination: this.pagination.getPageInfo(),
4576
- timestamp: Date.now()
4577
- };
4578
- localStorage.setItem("afs_state", JSON.stringify(state));
4579
- this.logger.debug("State saved");
4580
- }
4581
-
4582
- /**
4583
- * Restore saved state
4584
- * @public
4585
- */
4586
- restoreState() {
4587
- if (!this.options.get("preserveState")) return;
4588
- try {
4589
- const saved = localStorage.getItem("afs_state");
4590
- if (!saved) return;
4591
- const state = JSON.parse(saved);
4592
- if (Date.now() - state.timestamp > this.options.get("stateExpiry")) {
4593
- localStorage.removeItem("afs_state");
4594
- return;
4595
- }
4596
- this.setState(state);
4597
- this.logger.debug("State restored");
4598
- } catch (error) {
4599
- this.logger.error("Error restoring state:", error);
4600
- }
4601
- }
4602
-
4603
- /**
4604
- * Get current state
4605
- * @public
4606
- */
4607
- getState() {
4608
- return this.state.export();
4609
- }
4610
-
4611
- /**
4612
- * Set new state
4613
- * @public
4614
- */
4615
- setState(newState) {
4616
- this.state.import(newState);
4617
- this.refresh();
4618
- }
4619
-
4620
- // Update Methods
4621
- /**
4622
- * Update counter display
4623
- * @public
4624
- */
4625
- updateCounter() {
4626
- // Get counter element
4627
- const counterElement = document.querySelector(this.options.get("counterSelector"));
4628
- if (!counterElement) return;
4629
- try {
4630
- // Get count values
4631
- const total = this.items.length;
4632
- const visible = this.state.getState().items.visible.size;
4633
- const filtered = total - visible;
4634
-
4635
- // Get counter options with defaults
4636
- const counterOpts = this.options.get("counter") || Options.defaults.counter;
4637
- const template = counterOpts.template || "Showing {visible} of {total}";
4638
- const formatter = counterOpts.formatter || (num => num.toLocaleString());
4639
-
4640
- // Format numbers
4641
- const formattedVisible = formatter(visible);
4642
- const formattedTotal = formatter(total);
4643
- const formattedFiltered = formatter(filtered);
4644
-
4645
- // Build counter text
4646
- let counterText = template.replace("{visible}", formattedVisible).replace("{total}", formattedTotal).replace("{filtered}", formattedFiltered);
4647
-
4648
- // Add filtered count if enabled
4649
- if (filtered > 0 && counterOpts.showFiltered) {
4650
- const filteredTemplate = counterOpts.filteredTemplate || "({filtered} filtered)";
4651
- counterText += " " + filteredTemplate.replace("{filtered}", formattedFiltered);
4652
- }
4653
-
4654
- // Show no results message if applicable
4655
- if (visible === 0 && counterOpts.noResultsTemplate) {
4656
- counterText = counterOpts.noResultsTemplate;
4657
- }
4658
-
4659
- // Update counter element
4660
- counterElement.textContent = counterText;
4661
-
4662
- // Emit event
4663
- this.emit("counterUpdated", {
4664
- total,
4665
- visible,
4666
- filtered,
4667
- formattedTotal,
4668
- formattedVisible,
4669
- formattedFiltered
4670
- });
4671
- } catch (error) {
4672
- // Fallback to basic counter if anything fails
4673
- this.logger.error("Error updating counter:", error);
4674
- counterElement.textContent = `${this.state.getState().items.visible.size}/${this.items.length}`;
4675
- }
4676
- }
4677
-
4678
- /**
4679
- * Update options
4680
- * @public
4681
- */
4682
- updateOptions(newOptions) {
4683
- this.options.update(newOptions);
4684
- this.styleManager.updateStyles(newOptions);
4685
- this.refresh();
4686
- }
4687
-
4688
- /**
4689
- * Refresh system
4690
- * @public
4691
- */
4692
- refresh() {
4693
- this.logger.debug("Refreshing AFS");
4694
- this.items = this.container.querySelectorAll(this.options.get("itemSelector"));
4695
- this.state.setState("items.total", this.items.length);
4696
- this.filter.applyFilters();
4697
- this.search.search(this.search.getValue());
4698
- this.pagination.update();
4699
- this.emit("refreshed", {
4700
- itemCount: this.items.length
4701
- });
4702
- }
4703
-
4704
- // Event Handlers
4705
- /**
4706
- * Handle resize
4707
- * @private
4708
- */
4709
- handleResize = (() => debounce(() => {
4710
- this.emit("resize");
4711
- this.refresh();
4712
- }, 250))();
4713
-
4714
- /**
4715
- * Handle visibility change
4716
- * @private
4717
- */
4718
- handleVisibilityChange() {
4719
- if (document.hidden) {
4720
- this.emit("hidden");
4721
- this.saveState();
4722
- } else {
4723
- this.emit("visible");
4724
- this.restoreState();
4725
- }
4726
- }
4727
-
4728
- /**
4729
- * Setup mutation observer
4730
- * @private
4731
- */
4732
- setupMutationObserver() {
4733
- const observer = new MutationObserver(mutations => {
4734
- if (mutations.some(mutation => mutation.type === "childList")) {
4735
- this.refresh();
4736
- }
4737
- });
4738
- observer.observe(this.container, {
4739
- childList: true,
4740
- subtree: true
4741
- });
4742
- }
4743
-
4744
- // Utility Methods
4745
- /**
4746
- * Get version
4747
- * @public
4748
- */
4749
- getVersion() {
4750
- return VERSION;
4751
- }
4752
-
4753
- /**
4754
- * Check feature support
4755
- * @public
4756
- */
4757
- isFeatureSupported(feature) {
4758
- const supportedFeatures = {
4759
- search: !!this.search,
4760
- pagination: true,
4761
- animation: typeof document.createElement("div").style.transition !== "undefined",
4762
- urlState: typeof window.history.pushState === "function",
4763
- localStorage: (() => {
4764
- try {
4765
- localStorage.setItem("test", "test");
4766
- localStorage.removeItem("test");
4767
- return true;
4768
- } catch (e) {
4769
- return false;
4770
- }
4771
- })()
4772
- };
4773
- return !!supportedFeatures[feature];
4774
- }
4775
-
4776
- /**
4777
- * Destroy instance
4778
- * @public
4779
- */
4780
- destroy() {
4781
- this.logger.debug("Destroying AFS instance");
4782
-
4783
- // Remove event listeners
4784
- window.removeEventListener("resize", this.handleResize);
4785
- document.removeEventListener("visibilitychange", this.handleVisibilityChange);
4786
-
4787
- // Destroy features
4788
- this.filter.destroy();
4789
- this.search.destroy();
4790
- this.sort.destroy();
4791
- this.pagination.destroy();
4792
- this.rangeFilter.destroy();
4793
-
4794
- // Cleanup
4795
- this.styleManager.removeStyles();
4796
- this.state.reset();
4797
- localStorage.removeItem("afs_state");
4798
-
4799
- // Reset items
4800
- this.items.forEach(item => {
4801
- item.style = "";
4802
- item.classList.remove(this.options.get("hiddenClass"), this.options.get("activeClass"));
4803
- });
4804
- this.emit("destroyed");
4805
- }
4806
- }
4807
-
4808
- export { AFS, VERSION };
4809
- //# sourceMappingURL=AFS.esm.js.map