advanced-filter-system 1.0.3 → 1.0.5

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.js CHANGED
@@ -1,110 +1,113 @@
1
1
  (function (global, factory) {
2
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.AFS = {}));
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.AFS = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
+ /**
8
+ * @fileoverview Advanced Filter System for DOM elements
9
+ * @version 1.0.5
10
+ *
11
+ * A flexible and customizable filtering system that supports:
12
+ * - Multiple filtering modes (OR/AND)
13
+ * - Text search with debouncing
14
+ * - Multiple sorting criteria
15
+ * - Range filtering
16
+ * - URL state management
17
+ * - Animation and transitions
18
+ * - Results counter
19
+ */
20
+
21
+ /**
22
+ * Utility function for debouncing
23
+ * @param {Function} func - Function to debounce
24
+ * @param {number} wait - Delay in milliseconds
25
+ * @returns {Function} Debounced function
26
+ */
27
+ function debounce(func, wait) {
28
+ let timeout;
29
+ return function executedFunction() {
30
+ for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
31
+ args[_key] = arguments[_key];
32
+ }
33
+ const later = () => {
34
+ clearTimeout(timeout);
35
+ func(...args);
36
+ };
37
+ clearTimeout(timeout);
38
+ timeout = setTimeout(later, wait);
39
+ };
40
+ }
41
+ class AFS {
7
42
  /**
8
- * @fileoverview Advanced Filter System for DOM elements
9
- * @version 1.0.0
10
- *
11
- * A flexible and customizable filtering system that supports:
12
- * - Multiple filtering modes (OR/AND)
13
- * - Text search with debouncing
14
- * - Multiple sorting criteria
15
- * - Range filtering
16
- * - URL state management
17
- * - Animation and transitions
18
- * - Results counter
43
+ * @typedef {Object} FilterOptions
44
+ * @property {string} containerSelector - Main container selector
45
+ * @property {string} itemSelector - Items to filter selector
46
+ * @property {string} filterButtonSelector - Filter buttons selector
47
+ * @property {string} [searchInputSelector] - Search input selector
48
+ * @property {string} [counterSelector] - Results counter selector
49
+ * @property {string} [activeClass='active'] - Active state class
50
+ * @property {string} [hiddenClass='hidden'] - Hidden state class
51
+ * @property {number} [animationDuration=300] - Animation duration in ms
52
+ * @property {string} [filterMode='OR'] - Filter mode ('OR' or 'AND')
53
+ * @property {string[]} [searchKeys=['title']] - Data attributes to search in
54
+ * @property {number} [debounceTime=300] - Search debounce delay in ms
19
55
  */
20
56
 
21
57
  /**
22
- * Utility function for debouncing
23
- * @param {Function} func - Function to debounce
24
- * @param {number} wait - Delay in milliseconds
25
- * @returns {Function} Debounced function
58
+ * @param {FilterOptions} options - Filter configuration options
26
59
  */
27
- function debounce(func, wait) {
28
- let timeout;
29
- return function executedFunction() {
30
- for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
31
- args[_key] = arguments[_key];
32
- }
33
- const later = () => {
34
- clearTimeout(timeout);
35
- func(...args);
36
- };
37
- clearTimeout(timeout);
38
- timeout = setTimeout(later, wait);
60
+ constructor() {
61
+ let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
62
+ this.options = {
63
+ containerSelector: ".filter-container",
64
+ itemSelector: ".filter-item",
65
+ filterButtonSelector: ".btn-filter",
66
+ searchInputSelector: ".filter-search",
67
+ counterSelector: ".filter-counter",
68
+ activeClass: "active",
69
+ hiddenClass: "hidden",
70
+ animationDuration: 300,
71
+ filterMode: "OR",
72
+ searchKeys: ["title"],
73
+ debounceTime: 300,
74
+ ...options
39
75
  };
76
+
77
+ // Initialize elements
78
+ this.container = document.querySelector(this.options.containerSelector);
79
+ this.items = document.querySelectorAll(this.options.itemSelector);
80
+ this.filterButtons = document.querySelectorAll(this.options.filterButtonSelector);
81
+ this.searchInput = document.querySelector(this.options.searchInputSelector);
82
+ this.counter = document.querySelector(this.options.counterSelector);
83
+
84
+ // Initialize state
85
+ this.currentFilters = new Set(["*"]);
86
+ this.currentSearch = "";
87
+ this.visibleItems = new Set(this.items);
88
+ this.filterGroups = new Map();
89
+ this.groupMode = "OR"; // Default group mode
90
+
91
+ this.init();
40
92
  }
41
- class AFS {
42
- /**
43
- * @typedef {Object} FilterOptions
44
- * @property {string} containerSelector - Main container selector
45
- * @property {string} itemSelector - Items to filter selector
46
- * @property {string} filterButtonSelector - Filter buttons selector
47
- * @property {string} [searchInputSelector] - Search input selector
48
- * @property {string} [counterSelector] - Results counter selector
49
- * @property {string} [activeClass='active'] - Active state class
50
- * @property {string} [hiddenClass='hidden'] - Hidden state class
51
- * @property {number} [animationDuration=300] - Animation duration in ms
52
- * @property {string} [filterMode='OR'] - Filter mode ('OR' or 'AND')
53
- * @property {string[]} [searchKeys=['title']] - Data attributes to search in
54
- * @property {number} [debounceTime=300] - Search debounce delay in ms
55
- */
56
-
57
- /**
58
- * @param {FilterOptions} options - Filter configuration options
59
- */
60
- constructor() {
61
- let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
62
- this.options = {
63
- containerSelector: ".filter-container",
64
- itemSelector: ".filter-item",
65
- filterButtonSelector: ".btn-filter",
66
- searchInputSelector: ".filter-search",
67
- counterSelector: ".filter-counter",
68
- activeClass: "active",
69
- hiddenClass: "hidden",
70
- animationDuration: 300,
71
- filterMode: "OR",
72
- searchKeys: ["title"],
73
- debounceTime: 300,
74
- ...options
75
- };
76
-
77
- // Initialize elements
78
- this.container = document.querySelector(this.options.containerSelector);
79
- this.items = document.querySelectorAll(this.options.itemSelector);
80
- this.filterButtons = document.querySelectorAll(this.options.filterButtonSelector);
81
- this.searchInput = document.querySelector(this.options.searchInputSelector);
82
- this.counter = document.querySelector(this.options.counterSelector);
83
-
84
- // Initialize state
85
- this.currentFilters = new Set(["*"]);
86
- this.currentSearch = "";
87
- this.visibleItems = new Set(this.items);
88
- this.init();
89
- }
90
93
 
91
- /**
92
- * Initialize the filter system
93
- * @private
94
- */
95
- init() {
96
- this.addStyles();
97
- this.bindEvents();
98
- this.loadFromURL();
99
- this.updateCounter();
100
- }
94
+ /**
95
+ * Initialize the filter system
96
+ * @private
97
+ */
98
+ init() {
99
+ this.addStyles();
100
+ this.bindEvents();
101
+ this.loadFromURL();
102
+ this.updateCounter();
103
+ }
101
104
 
102
- /**
103
- * Add required styles to document
104
- * @private
105
- */
106
- addStyles() {
107
- const styles = `
105
+ /**
106
+ * Add required styles to document
107
+ * @private
108
+ */
109
+ addStyles() {
110
+ const styles = `
108
111
  .${this.options.hiddenClass} {
109
112
  display: none !important;
110
113
  }
@@ -125,395 +128,752 @@
125
128
  opacity: 1;
126
129
  }
127
130
  `;
128
- const styleSheet = document.createElement("style");
129
- styleSheet.textContent = styles;
130
- document.head.appendChild(styleSheet);
131
- }
131
+ const styleSheet = document.createElement("style");
132
+ styleSheet.textContent = styles;
133
+ document.head.appendChild(styleSheet);
134
+ }
132
135
 
133
- /**
134
- * Bind all event listeners
135
- * @private
136
- */
137
- bindEvents() {
138
- this.filterButtons.forEach(button => {
139
- button.addEventListener("click", () => this.handleFilterClick(button));
140
- });
141
- if (this.searchInput) {
142
- this.searchInput.addEventListener("input", debounce(e => {
143
- this.search(e.target.value);
144
- }, this.options.debounceTime));
145
- }
146
- window.addEventListener("popstate", () => this.loadFromURL());
136
+ /**
137
+ * Bind all event listeners
138
+ * @private
139
+ */
140
+ bindEvents() {
141
+ this.filterButtons.forEach(button => {
142
+ button.addEventListener("click", () => this.handleFilterClick(button));
143
+ });
144
+ if (this.searchInput) {
145
+ this.searchInput.addEventListener("input", debounce(e => {
146
+ this.search(e.target.value);
147
+ }, this.options.debounceTime));
147
148
  }
149
+ window.addEventListener("popstate", () => this.loadFromURL());
150
+ }
148
151
 
149
- /**
150
- * Handle filter button clicks
151
- * @private
152
- * @param {HTMLElement} button - Clicked filter button
153
- */
154
- handleFilterClick(button) {
155
- const filterValue = button.dataset.filter;
156
- if (filterValue === "*") {
157
- this.resetFilters();
158
- } else {
159
- this.toggleFilter(filterValue, button);
160
- }
161
- this.filter();
162
- this.updateURL();
152
+ /**
153
+ * Handle filter button clicks
154
+ * @private
155
+ * @param {HTMLElement} button - Clicked filter button
156
+ */
157
+ handleFilterClick(button) {
158
+ const filterValue = button.dataset.filter;
159
+ if (filterValue === "*") {
160
+ this.resetFilters();
161
+ } else {
162
+ this.toggleFilter(filterValue, button);
163
163
  }
164
+ this.filter();
165
+ this.updateURL();
166
+ }
164
167
 
165
- /**
166
- * Reset all filters to default state
167
- * @private
168
- */
169
- resetFilters() {
170
- this.filterButtons.forEach(btn => btn.classList.remove(this.options.activeClass));
171
- this.currentFilters.clear();
172
- this.currentFilters.add("*");
173
- this.filterButtons[0].classList.add(this.options.activeClass);
174
- this.resetCounter();
175
- }
168
+ /**
169
+ * Reset all filters to default state
170
+ * @private
171
+ */
172
+ resetFilters() {
173
+ this.filterButtons.forEach(btn => btn.classList.remove(this.options.activeClass));
174
+ this.currentFilters.clear();
175
+ this.currentFilters.add("*");
176
+ this.filterButtons[0].classList.add(this.options.activeClass);
177
+ this.resetCounter();
178
+ }
176
179
 
177
- /**
178
- * Reset visible items counter
179
- * @private
180
- */
181
- resetCounter() {
182
- this.visibleItems = new Set(this.items);
183
- this.updateCounter();
184
- }
180
+ /**
181
+ * Reset visible items counter
182
+ * @private
183
+ */
184
+ resetCounter() {
185
+ this.visibleItems = new Set(this.items);
186
+ this.updateCounter();
187
+ }
185
188
 
186
- /**
187
- * Toggle individual filter state
188
- * @private
189
- * @param {string} filterValue - Filter value to toggle
190
- * @param {HTMLElement} button - Filter button element
191
- */
192
- toggleFilter(filterValue, button) {
193
- this.currentFilters.delete("*");
194
- this.filterButtons[0].classList.remove(this.options.activeClass);
195
- if (button.classList.contains(this.options.activeClass)) {
196
- button.classList.remove(this.options.activeClass);
197
- this.currentFilters.delete(filterValue);
198
- if (this.currentFilters.size === 0) {
199
- this.resetFilters();
200
- }
201
- } else {
202
- button.classList.add(this.options.activeClass);
203
- this.currentFilters.add(filterValue);
189
+ /**
190
+ * Toggle individual filter state
191
+ * @private
192
+ * @param {string} filterValue - Filter value to toggle
193
+ * @param {HTMLElement} button - Filter button element
194
+ */
195
+ toggleFilter(filterValue, button) {
196
+ this.currentFilters.delete("*");
197
+ this.filterButtons[0].classList.remove(this.options.activeClass);
198
+ if (button.classList.contains(this.options.activeClass)) {
199
+ button.classList.remove(this.options.activeClass);
200
+ this.currentFilters.delete(filterValue);
201
+
202
+ // If no filters are selected, reset to default state and clear URL
203
+ if (this.currentFilters.size === 0) {
204
+ this.resetFilters();
205
+ window.history.pushState({}, "", window.location.pathname);
206
+ return;
204
207
  }
208
+ } else {
209
+ button.classList.add(this.options.activeClass);
210
+ this.currentFilters.add(filterValue);
205
211
  }
212
+ }
206
213
 
207
- /**
208
- * Apply current filters to items
209
- * @public
210
- */
211
- filter() {
212
- this.visibleItems.clear(); // Start with an empty set
213
-
214
+ /**
215
+ * Apply current filters to items
216
+ * @public
217
+ */
218
+ /**
219
+ * Apply current filters to items
220
+ * @public
221
+ */
222
+ filter() {
223
+ // Store the original filter logic
224
+ const standardFilter = () => {
225
+ this.visibleItems.clear();
214
226
  this.items.forEach(item => {
215
227
  if (this.currentFilters.has("*")) {
216
228
  this.showItem(item);
217
- this.visibleItems.add(item); // Add visible item to the set
229
+ this.visibleItems.add(item);
218
230
  } else {
219
231
  const itemCategories = new Set(item.dataset.categories?.split(" ") || []);
220
232
  const matchesFilter = this.options.filterMode === "OR" ? this.matchesAnyFilter(itemCategories) : this.matchesAllFilters(itemCategories);
221
233
  if (matchesFilter) {
222
234
  this.showItem(item);
223
- this.visibleItems.add(item); // Add visible item to the set
235
+ this.visibleItems.add(item);
224
236
  } else {
225
237
  this.hideItem(item);
226
238
  }
227
239
  }
228
240
  });
229
- setTimeout(() => {
230
- this.updateCounter();
231
- }, this.options.animationDuration);
232
- }
241
+ };
233
242
 
234
- /**
235
- * Check if item matches any active filter (OR mode)
236
- * @private
237
- * @param {Set} itemCategories - Item's categories
238
- * @returns {boolean} Whether item matches any filter
239
- */
240
- matchesAnyFilter(itemCategories) {
241
- return [...this.currentFilters].some(filter => {
242
- const [type, value] = filter.split(":");
243
- return itemCategories.has(`${type}:${value}`);
243
+ // Check if we should use group filtering or standard filtering
244
+ if (this.filterGroups.size === 0) {
245
+ standardFilter();
246
+ } else {
247
+ this.visibleItems.clear();
248
+ this.items.forEach(item => {
249
+ if (this.currentFilters.has("*")) {
250
+ this.showItem(item);
251
+ this.visibleItems.add(item);
252
+ } else {
253
+ const itemCategories = new Set(item.dataset.categories?.split(" ") || []);
254
+ const matchesGroups = this.matchesFilterGroups(itemCategories);
255
+ if (matchesGroups) {
256
+ this.showItem(item);
257
+ this.visibleItems.add(item);
258
+ } else {
259
+ this.hideItem(item);
260
+ }
261
+ }
244
262
  });
245
263
  }
264
+ setTimeout(() => {
265
+ this.updateCounter();
266
+ }, this.options.animationDuration);
267
+ }
246
268
 
247
- /**
248
- * Check if item matches all active filters (AND mode)
249
- * @private
250
- * @param {Set} itemCategories - Item's categories
251
- * @returns {boolean} Whether item matches all filters
252
- */
253
- matchesAllFilters(itemCategories) {
254
- return [...this.currentFilters].every(filter => {
255
- const [type, value] = filter.split(":");
256
- return itemCategories.has(`${type}:${value}`);
257
- });
258
- }
269
+ /**
270
+ * Add or update a filter group
271
+ * @public
272
+ * @param {string} groupId - Group identifier
273
+ * @param {string[]} filters - Array of filter values
274
+ * @param {string} [operator='OR'] - Operator within group ('AND' or 'OR')
275
+ * @returns {boolean} Success status
276
+ */
277
+ addFilterGroup(groupId, filters) {
278
+ let operator = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "OR";
279
+ try {
280
+ // Validate inputs
281
+ if (!groupId || !Array.isArray(filters)) {
282
+ console.warn("Invalid group parameters");
283
+ return false;
284
+ }
285
+ const validOperator = operator.toUpperCase();
286
+ if (!["AND", "OR"].includes(validOperator)) {
287
+ console.warn('Invalid operator. Using default "OR"');
288
+ operator = "OR";
289
+ }
259
290
 
260
- /**
261
- * Show an item with animation
262
- * @private
263
- * @param {HTMLElement} item - Item to show
264
- */
265
- showItem(item) {
266
- this.visibleItems.add(item);
267
- item.classList.remove(this.options.hiddenClass);
268
- item.style.opacity = "0";
269
- item.style.transform = "scale(0.95)";
270
- item.offsetHeight;
271
- requestAnimationFrame(() => {
272
- item.style.opacity = "1";
273
- item.style.transform = "scale(1)";
291
+ // Create or update group
292
+ this.filterGroups.set(groupId, {
293
+ filters: new Set(filters),
294
+ operator: validOperator
274
295
  });
296
+
297
+ // Only update if we have active groups
298
+ if (this.filterGroups.size > 0) {
299
+ this.updateFiltersFromGroups();
300
+ this.filter();
301
+ this.updateURL();
302
+ }
303
+ return true;
304
+ } catch (error) {
305
+ console.error("Error adding filter group:", error);
306
+ return false;
275
307
  }
308
+ }
276
309
 
277
- /**
278
- * Hide an item with animation
279
- * @private
280
- * @param {HTMLElement} item - Item to hide
281
- */
282
- hideItem(item) {
283
- item.style.opacity = "0";
284
- item.style.transform = "scale(0.95)";
285
- setTimeout(() => {
286
- if (item.style.opacity === "0") {
287
- item.classList.add(this.options.hiddenClass);
288
- this.visibleItems.delete(item);
289
- }
290
- }, this.options.animationDuration);
310
+ /**
311
+ * Set how groups combine with each other
312
+ * @public
313
+ * @param {string} mode - Mode for combining groups ('AND' or 'OR')
314
+ */
315
+ setGroupMode(mode) {
316
+ const validMode = mode.toUpperCase();
317
+ if (["AND", "OR"].includes(validMode)) {
318
+ this.groupMode = validMode;
319
+ if (this.filterGroups.size > 0) {
320
+ this.filter();
321
+ }
291
322
  }
323
+ }
292
324
 
293
- /**
294
- * Search items by text
295
- * @public
296
- * @param {string} query - Search query
297
- */
298
- search(query) {
299
- this.currentSearch = query.toLowerCase().trim();
300
- this.items.forEach(item => {
301
- const searchText = this.options.searchKeys.map(key => item.dataset[key] || "").join(" ").toLowerCase();
302
- const matchesSearch = this.currentSearch === "" || searchText.includes(this.currentSearch);
303
- if (matchesSearch) {
304
- this.showItem(item);
305
- } else {
306
- this.hideItem(item);
307
- }
308
- });
325
+ /**
326
+ * Remove a filter group
327
+ * @public
328
+ * @param {string} groupId - Group identifier
329
+ * @returns {boolean} Success status
330
+ */
331
+ removeFilterGroup(groupId) {
332
+ if (this.filterGroups.has(groupId)) {
333
+ this.filterGroups.delete(groupId);
334
+
335
+ // If no groups left, revert to normal filtering
336
+ if (this.filterGroups.size === 0) {
337
+ this.resetFilters();
338
+ } else {
339
+ this.updateFiltersFromGroups();
340
+ }
341
+ this.filter();
309
342
  this.updateURL();
310
- setTimeout(() => {
311
- this.updateCounter();
312
- }, this.options.animationDuration);
343
+ return true;
313
344
  }
345
+ return false;
346
+ }
314
347
 
315
- /**
316
- * Sort items by multiple criteria
317
- * @public
318
- * @param {Array<{key: string, direction: string}>} criteria - Sort criteria
319
- */
320
- sortMultiple(criteria) {
321
- const items = [...this.items];
322
- items.sort((a, b) => {
323
- for (const criterion of criteria) {
324
- const valueA = a.dataset[criterion.key];
325
- const valueB = b.dataset[criterion.key];
326
- const comparison = criterion.direction === "asc" ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
327
- if (comparison !== 0) return comparison;
328
- }
329
- return 0;
330
- });
331
- items.forEach(item => this.container.appendChild(item));
348
+ /**
349
+ * Update filters based on groups
350
+ * @private
351
+ */
352
+ updateFiltersFromGroups() {
353
+ // Only process if we have groups
354
+ if (this.filterGroups.size === 0) return;
355
+
356
+ // Clear current filters except '*'
357
+ if (!this.currentFilters.has("*")) {
358
+ this.currentFilters.clear();
332
359
  }
333
360
 
334
- /**
335
- * Filter items by numeric range
336
- * @public
337
- * @param {string} key - Data attribute key
338
- * @param {number} min - Minimum value
339
- * @param {number} max - Maximum value
340
- */
341
- addRangeFilter(key, min, max) {
342
- this.items.forEach(item => {
343
- const value = parseFloat(item.dataset[key]);
344
- const inRange = value >= min && value <= max;
345
- if (inRange) {
346
- this.showItem(item);
347
- } else {
348
- this.hideItem(item);
361
+ // Combine all group filters
362
+ for (const group of this.filterGroups.values()) {
363
+ group.filters.forEach(filter => {
364
+ if (filter !== "*") {
365
+ this.currentFilters.add(filter);
349
366
  }
350
367
  });
351
- setTimeout(() => {
352
- this.updateCounter();
353
- }, this.options.animationDuration);
354
368
  }
369
+ }
355
370
 
356
- /**
357
- * Update URL with current filter state
358
- * @private
359
- */
360
- updateURL() {
361
- const params = new URLSearchParams();
371
+ /**
372
+ * Check if item matches any active filter (OR mode)
373
+ * @private
374
+ * @param {Set} itemCategories - Item's categories
375
+ * @returns {boolean} Whether item matches any filter
376
+ */
377
+ matchesAnyFilter(itemCategories) {
378
+ return [...this.currentFilters].some(filter => {
379
+ const [type, value] = filter.split(":");
380
+ return itemCategories.has(`${type}:${value}`);
381
+ });
382
+ }
362
383
 
363
- // Séparer les filtres par type
364
- const filtersByType = {};
365
- for (const filter of this.currentFilters) {
366
- if (filter !== "*") {
367
- const [type, value] = filter.split(":");
368
- if (!filtersByType[type]) {
369
- filtersByType[type] = new Set();
370
- }
371
- filtersByType[type].add(value);
372
- }
373
- }
384
+ /**
385
+ * Check if item matches all active filters (AND mode)
386
+ * @private
387
+ * @param {Set} itemCategories - Item's categories
388
+ * @returns {boolean} Whether item matches all filters
389
+ */
390
+ matchesAllFilters(itemCategories) {
391
+ return [...this.currentFilters].every(filter => {
392
+ const [type, value] = filter.split(":");
393
+ return itemCategories.has(`${type}:${value}`);
394
+ });
395
+ }
374
396
 
375
- // Ajouter chaque type de filtre à l'URL
376
- Object.entries(filtersByType).forEach(_ref => {
377
- let [type, values] = _ref;
378
- params.set(type, Array.from(values).join(","));
379
- });
380
- if (this.currentSearch) {
381
- params.set("search", this.currentSearch);
397
+ /**
398
+ * Show an item with animation
399
+ * @private
400
+ * @param {HTMLElement} item - Item to show
401
+ */
402
+ showItem(item) {
403
+ this.visibleItems.add(item);
404
+ item.classList.remove(this.options.hiddenClass);
405
+ item.style.opacity = "0";
406
+ item.style.transform = "scale(0.95)";
407
+ item.offsetHeight;
408
+ requestAnimationFrame(() => {
409
+ item.style.opacity = "1";
410
+ item.style.transform = "scale(1)";
411
+ });
412
+ }
413
+
414
+ /**
415
+ * Hide an item with animation
416
+ * @private
417
+ * @param {HTMLElement} item - Item to hide
418
+ */
419
+ hideItem(item) {
420
+ item.style.opacity = "0";
421
+ item.style.transform = "scale(0.95)";
422
+ setTimeout(() => {
423
+ if (item.style.opacity === "0") {
424
+ item.classList.add(this.options.hiddenClass);
425
+ this.visibleItems.delete(item);
382
426
  }
383
- const newURL = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
384
- window.history.pushState({}, "", newURL);
385
- }
427
+ }, this.options.animationDuration);
428
+ }
386
429
 
387
- /**
388
- * Load filter state from URL
389
- * @private
390
- */
391
- loadFromURL() {
392
- const params = new URLSearchParams(window.location.search);
393
- this.currentFilters.clear();
430
+ /**
431
+ * Search items by text
432
+ * @public
433
+ * @param {string} query - Search query
434
+ */
435
+ search(query) {
436
+ this.currentSearch = query.toLowerCase().trim();
437
+ this.items.forEach(item => {
438
+ const searchText = this.options.searchKeys.map(key => item.dataset[key] || "").join(" ").toLowerCase();
439
+ const matchesSearch = this.currentSearch === "" || searchText.includes(this.currentSearch);
440
+ if (matchesSearch) {
441
+ this.showItem(item);
442
+ } else {
443
+ this.hideItem(item);
444
+ }
445
+ });
446
+ this.updateURL();
447
+ setTimeout(() => {
448
+ this.updateCounter();
449
+ }, this.options.animationDuration);
450
+ }
394
451
 
395
- // Si aucun filtre n'est présent, utiliser '*'
396
- let hasFilters = false;
452
+ /**
453
+ * Sort items by multiple criteria
454
+ * @public
455
+ * @param {Array<{key: string, direction: string}>} criteria - Sort criteria
456
+ */
457
+ sortMultiple(criteria) {
458
+ const items = [...this.items];
459
+ items.sort((a, b) => {
460
+ for (const criterion of criteria) {
461
+ const valueA = a.dataset[criterion.key];
462
+ const valueB = b.dataset[criterion.key];
463
+ const comparison = criterion.direction === "asc" ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
464
+ if (comparison !== 0) return comparison;
465
+ }
466
+ return 0;
467
+ });
468
+ items.forEach(item => this.container.appendChild(item));
469
+ }
397
470
 
398
- // Parcourir tous les paramètres
399
- for (const [type, values] of params.entries()) {
400
- if (type !== "search") {
401
- hasFilters = true;
402
- values.split(",").forEach(value => {
403
- this.currentFilters.add(`${type}:${value}`);
404
- });
405
- }
471
+ /**
472
+ * Filter items by numeric range
473
+ * @public
474
+ * @param {string} key - Data attribute key
475
+ * @param {number} min - Minimum value
476
+ * @param {number} max - Maximum value
477
+ */
478
+ addRangeFilter(key, min, max) {
479
+ this.items.forEach(item => {
480
+ const value = parseFloat(item.dataset[key]);
481
+ const inRange = value >= min && value <= max;
482
+ if (inRange) {
483
+ this.showItem(item);
484
+ } else {
485
+ this.hideItem(item);
406
486
  }
407
- if (!hasFilters) {
408
- this.currentFilters.add("*");
487
+ });
488
+ setTimeout(() => {
489
+ this.updateCounter();
490
+ }, this.options.animationDuration);
491
+ }
492
+
493
+ /**
494
+ * Check if item matches filter groups
495
+ * @private
496
+ * @param {Set} itemCategories - Item's categories
497
+ * @returns {boolean} Whether item matches the group filters
498
+ */
499
+ matchesFilterGroups(itemCategories) {
500
+ const groupMatches = [...this.filterGroups.values()].map(group => {
501
+ const groupFilters = [...group.filters];
502
+ if (groupFilters.length === 0) return true;
503
+ return group.operator === "OR" ? groupFilters.some(filter => itemCategories.has(filter)) : groupFilters.every(filter => itemCategories.has(filter));
504
+ });
505
+ return this.groupMode === "OR" ? groupMatches.some(matches => matches) : groupMatches.every(matches => matches);
506
+ }
507
+
508
+ /**
509
+ * Update URL with current filter state
510
+ * @private
511
+ */
512
+ updateURL() {
513
+ // If only "*" filter is active or no filters are active, clear the URL
514
+ if (this.currentFilters.size === 0 || this.currentFilters.size === 1 && this.currentFilters.has("*")) {
515
+ window.history.pushState({}, "", window.location.pathname);
516
+ return;
517
+ }
518
+ const params = new URLSearchParams(window.location.search);
519
+
520
+ // Add groups to URL if they exist
521
+ if (this.filterGroups.size > 0) {
522
+ for (const [groupId, group] of this.filterGroups.entries()) {
523
+ params.set(`group_${groupId}`, [...group.filters].join(","));
524
+ params.set(`groupOp_${groupId}`, group.operator.toLowerCase());
409
525
  }
526
+ params.set("groupMode", this.groupMode.toLowerCase());
527
+ }
410
528
 
411
- // Update active buttons
412
- this.filterButtons.forEach(button => {
413
- const filterValue = button.dataset.filter;
414
- if (this.currentFilters.has(filterValue) || filterValue === "*" && this.currentFilters.has("*")) {
415
- button.classList.add(this.options.activeClass);
416
- } else {
417
- button.classList.remove(this.options.activeClass);
529
+ // Separate filters by type
530
+ const filtersByType = {};
531
+ for (const filter of this.currentFilters) {
532
+ if (filter !== "*") {
533
+ const [type, value] = filter.split(":");
534
+ if (!filtersByType[type]) {
535
+ filtersByType[type] = new Set();
418
536
  }
419
- });
537
+ filtersByType[type].add(value);
538
+ }
539
+ }
540
+
541
+ // Add each filter type to the URL
542
+ Object.entries(filtersByType).forEach(_ref => {
543
+ let [type, values] = _ref;
544
+ params.set(type, Array.from(values).join(","));
545
+ });
546
+ if (this.currentSearch) {
547
+ params.set("search", this.currentSearch);
548
+ }
549
+ const newURL = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
550
+ window.history.pushState({}, "", newURL);
551
+ }
420
552
 
421
- // Load search
422
- const search = params.get("search") || "";
423
- if (this.searchInput) {
424
- this.searchInput.value = search;
553
+ /**
554
+ * Load filter state from URL
555
+ * @private
556
+ */
557
+ loadFromURL() {
558
+ const params = new URLSearchParams(window.location.search);
559
+
560
+ // Load groups if they exist
561
+ this.filterGroups.clear();
562
+ for (const [key, value] of params.entries()) {
563
+ if (key.startsWith("group_")) {
564
+ const groupId = key.replace("group_", "");
565
+ const operator = params.get(`groupOp_${groupId}`)?.toUpperCase() || "OR";
566
+ const filters = value.split(",");
567
+ this.addFilterGroup(groupId, filters, operator);
425
568
  }
426
- this.filter();
427
- if (search) {
428
- this.search(search);
569
+ }
570
+
571
+ // Set group mode if present
572
+ const groupMode = params.get("groupMode")?.toUpperCase();
573
+ if (groupMode && ["AND", "OR"].includes(groupMode)) {
574
+ this.groupMode = groupMode;
575
+ }
576
+ this.currentFilters.clear();
577
+
578
+ // Si aucun filtre n'est présent, utiliser '*'
579
+ let hasFilters = false;
580
+
581
+ // Parcourir tous les paramètres
582
+ for (const [type, values] of params.entries()) {
583
+ if (type !== "search") {
584
+ hasFilters = true;
585
+ values.split(",").forEach(value => {
586
+ this.currentFilters.add(`${type}:${value}`);
587
+ });
429
588
  }
430
589
  }
590
+ if (!hasFilters) {
591
+ this.currentFilters.add("*");
592
+ }
431
593
 
432
- /**
433
- * Update results counter
434
- * @private
435
- * @returns {{total: number, visible: number}}
436
- */
437
- updateCounter() {
438
- const total = this.items.length;
439
- const visible = this.visibleItems.size;
440
- if (this.counter) {
441
- this.counter.textContent = `Showing ${visible} of ${total}`;
594
+ // Update active buttons
595
+ this.filterButtons.forEach(button => {
596
+ const filterValue = button.dataset.filter;
597
+ if (this.currentFilters.has(filterValue) || filterValue === "*" && this.currentFilters.has("*")) {
598
+ button.classList.add(this.options.activeClass);
599
+ } else {
600
+ button.classList.remove(this.options.activeClass);
442
601
  }
443
- return {
444
- total,
445
- visible
446
- };
602
+ });
603
+
604
+ // Load search
605
+ const search = params.get("search") || "";
606
+ if (this.searchInput) {
607
+ this.searchInput.value = search;
447
608
  }
609
+ this.filter();
610
+ if (search) {
611
+ this.search(search);
612
+ }
613
+ }
448
614
 
449
- /**
450
- * Set filter logic mode (alias for setFilterMode)
451
- * @public
452
- * @param {string} logic - New filter logic ('AND' or 'OR')
453
- */
454
- setLogic(logic) {
455
- if (typeof logic === 'boolean') {
456
- // Handle boolean input (true = AND, false = OR)
457
- this.options.filterMode = logic ? 'AND' : 'OR';
458
- this.filter();
459
- return;
615
+ /**
616
+ * Update results counter
617
+ * @private
618
+ * @returns {{total: number, visible: number}}
619
+ */
620
+ updateCounter() {
621
+ const total = this.items.length;
622
+ const visible = this.visibleItems.size;
623
+ if (this.counter) {
624
+ this.counter.textContent = `Showing ${visible} of ${total}`;
625
+ }
626
+ return {
627
+ total,
628
+ visible
629
+ };
630
+ }
631
+
632
+ /**
633
+ * Set animation options
634
+ * @public
635
+ * @param {Object} options - Animation options
636
+ */
637
+ setAnimationOptions() {
638
+ let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
639
+ this.options.animationDuration = options.duration || this.options.animationDuration;
640
+ this.options.animationType = options.type || "ease-out";
641
+ this.addStyles(); // Refresh styles with new options
642
+ }
643
+
644
+ /**
645
+ * Event handling system
646
+ */
647
+ addEventSystem() {
648
+ this.events = {};
649
+ this.on = (eventName, callback) => {
650
+ if (!this.events[eventName]) {
651
+ this.events[eventName] = [];
460
652
  }
461
- const mode = logic.toUpperCase();
462
- if (['OR', 'AND'].includes(mode)) {
463
- this.options.filterMode = mode;
464
- this.filter();
653
+ this.events[eventName].push(callback);
654
+ };
655
+ this.emit = (eventName, data) => {
656
+ if (this.events[eventName]) {
657
+ this.events[eventName].forEach(callback => callback(data));
465
658
  }
466
- }
659
+ };
660
+ }
467
661
 
468
- /**
469
- * Change filter mode
470
- * @public
471
- * @param {string} mode - New filter mode ('OR' or 'AND')
472
- */
473
- setFilterMode(mode) {
474
- if (["OR", "AND"].includes(mode.toUpperCase())) {
475
- this.options.filterMode = mode.toUpperCase();
476
- this.filter();
662
+ /**
663
+ * Add pagination
664
+ * @public
665
+ * @param {number} itemsPerPage - Number of items per page
666
+ */
667
+ setPagination(itemsPerPage) {
668
+ this.pagination = {
669
+ currentPage: 1,
670
+ itemsPerPage: itemsPerPage,
671
+ totalPages: Math.ceil(this.visibleItems.size / itemsPerPage)
672
+ };
673
+ this.updatePagination();
674
+ }
675
+ updatePagination() {
676
+ const start = (this.pagination.currentPage - 1) * this.pagination.itemsPerPage;
677
+ const end = start + this.pagination.itemsPerPage;
678
+ [...this.visibleItems].forEach((item, index) => {
679
+ if (index >= start && index < end) {
680
+ this.showItem(item);
681
+ } else {
682
+ this.hideItem(item);
477
683
  }
478
- }
684
+ });
685
+ }
686
+
687
+ /**
688
+ * Enable analytics tracking
689
+ * @public
690
+ * @param {Function} callback - Analytics callback function
691
+ */
692
+ enableAnalytics(callback) {
693
+ this.analyticsCallback = callback;
694
+ this.on("filter", data => {
695
+ this.analyticsCallback({
696
+ type: "filter",
697
+ filters: [...this.currentFilters],
698
+ visibleItems: this.visibleItems.size,
699
+ timestamp: new Date().toISOString()
700
+ });
701
+ });
702
+ }
703
+
704
+ /**
705
+ * Sort with custom comparator
706
+ * @public
707
+ * @param {string} key - Data attribute key
708
+ * @param {Function} comparator - Custom comparison function
709
+ */
710
+ sortWithComparator(key, comparator) {
711
+ const items = [...this.items];
712
+ items.sort((a, b) => {
713
+ const valueA = a.dataset[key];
714
+ const valueB = b.dataset[key];
715
+ return comparator(valueA, valueB);
716
+ });
717
+ items.forEach(item => this.container.appendChild(item));
718
+ }
719
+
720
+ /**
721
+ * Add responsive behavior
722
+ * @public
723
+ * @param {Object} breakpoints - Breakpoint configurations
724
+ */
725
+ setResponsiveOptions(breakpoints) {
726
+ window.addEventListener("resize", debounce(() => {
727
+ const width = window.innerWidth;
728
+ for (const [breakpoint, options] of Object.entries(breakpoints)) {
729
+ if (width <= parseInt(breakpoint)) {
730
+ Object.assign(this.options, options);
731
+ this.filter();
732
+ break;
733
+ }
734
+ }
735
+ }, 250));
736
+ }
479
737
 
480
- /**
481
- * Add filter by type and value
482
- * @public
483
- * @param {string} type - Filter type
484
- * @param {string} value - Filter value
485
- */
486
- addFilter(type, value) {
487
- this.currentFilters.add(`${type}:${value}`);
738
+ /**
739
+ * Enable keyboard navigation
740
+ * @public
741
+ */
742
+ enableKeyboardNavigation() {
743
+ document.addEventListener("keydown", e => {
744
+ if (e.key === "Enter" && document.activeElement.classList.contains(this.options.filterButtonSelector.slice(1))) {
745
+ document.activeElement.click();
746
+ }
747
+ });
748
+ }
749
+
750
+ /**
751
+ * Export current filter state
752
+ * @public
753
+ * @returns {Object} Filter state
754
+ */
755
+ exportState() {
756
+ return {
757
+ filters: [...this.currentFilters],
758
+ search: this.currentSearch,
759
+ mode: this.options.filterMode,
760
+ timestamp: new Date().toISOString()
761
+ };
762
+ }
763
+
764
+ /**
765
+ * Import filter state
766
+ * @public
767
+ * @param {Object} state - Filter state to import
768
+ */
769
+ importState(state) {
770
+ if (state.filters) {
771
+ this.currentFilters = new Set(state.filters);
772
+ this.currentSearch = state.search || "";
773
+ this.options.filterMode = state.mode || "OR";
488
774
  this.filter();
489
775
  this.updateURL();
490
776
  }
777
+ }
491
778
 
492
- /**
493
- * Remove filter by type and value
494
- * @public
495
- * @param {string} type - Filter type
496
- */
497
- removeFilter(type, value) {
498
- this.currentFilters.delete(`${type}:${value}`);
499
- if (this.currentFilters.size === 0) {
500
- this.currentFilters.add("*");
501
- }
779
+ /**
780
+ * Save current filter state as preset
781
+ * @public
782
+ * @param {string} presetName - Name of the preset
783
+ */
784
+ savePreset(presetName) {
785
+ const preset = {
786
+ filters: [...this.currentFilters],
787
+ search: this.currentSearch,
788
+ mode: this.options.filterMode
789
+ };
790
+ localStorage.setItem(`afs_preset_${presetName}`, JSON.stringify(preset));
791
+ }
792
+
793
+ /**
794
+ * Load filter preset
795
+ * @public
796
+ * @param {string} presetName - Name of the preset to load
797
+ */
798
+ loadPreset(presetName) {
799
+ const preset = JSON.parse(localStorage.getItem(`afs_preset_${presetName}`));
800
+ if (preset) {
801
+ this.currentFilters = new Set(preset.filters);
802
+ this.currentSearch = preset.search;
803
+ this.options.filterMode = preset.mode;
502
804
  this.filter();
503
805
  this.updateURL();
504
806
  }
807
+ }
505
808
 
506
- /**
507
- * Get active filters by type
508
- * @public
509
- * @param {string} type - Filter type
510
- */
511
- getActiveFiltersByType(type) {
512
- return [...this.currentFilters].filter(filter => filter.startsWith(`${type}:`)).map(filter => filter.split(":")[1]);
809
+ /**
810
+ * Set filter logic mode (alias for setFilterMode)
811
+ * @public
812
+ * @param {string} logic - New filter logic ('AND' or 'OR')
813
+ */
814
+ setLogic(logic) {
815
+ if (typeof logic === "boolean") {
816
+ // Handle boolean input (true = AND, false = OR)
817
+ this.options.filterMode = logic ? "AND" : "OR";
818
+ this.filter();
819
+ return;
820
+ }
821
+ const mode = logic.toUpperCase();
822
+ if (["OR", "AND"].includes(mode)) {
823
+ this.options.filterMode = mode;
824
+ this.filter();
513
825
  }
514
826
  }
515
827
 
516
- exports.AFS = AFS;
828
+ /**
829
+ * Change filter mode
830
+ * @public
831
+ * @param {string} mode - New filter mode ('OR' or 'AND')
832
+ */
833
+ setFilterMode(mode) {
834
+ if (["OR", "AND"].includes(mode.toUpperCase())) {
835
+ this.options.filterMode = mode.toUpperCase();
836
+ this.filter();
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Add filter by type and value
842
+ * @public
843
+ * @param {string} type - Filter type
844
+ * @param {string} value - Filter value
845
+ */
846
+ addFilter(type, value) {
847
+ this.currentFilters.add(`${type}:${value}`);
848
+ this.filter();
849
+ this.updateURL();
850
+ }
851
+
852
+ /**
853
+ * Remove filter by type and value
854
+ * @public
855
+ * @param {string} type - Filter type
856
+ */
857
+ removeFilter(type, value) {
858
+ this.currentFilters.delete(`${type}:${value}`);
859
+ if (this.currentFilters.size === 0) {
860
+ this.currentFilters.add("*");
861
+ }
862
+ this.filter();
863
+ this.updateURL();
864
+ }
865
+
866
+ /**
867
+ * Get active filters by type
868
+ * @public
869
+ * @param {string} type - Filter type
870
+ */
871
+ getActiveFiltersByType(type) {
872
+ return [...this.currentFilters].filter(filter => filter.startsWith(`${type}:`)).map(filter => filter.split(":")[1]);
873
+ }
874
+ }
875
+
876
+ exports.AFS = AFS;
517
877
 
518
878
  }));
519
879
  //# sourceMappingURL=AFS.js.map