advanced-filter-system 1.0.2 → 1.0.4

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.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
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,378 +128,743 @@
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
+ if (this.currentFilters.size === 0) {
202
+ this.resetFilters();
204
203
  }
204
+ } else {
205
+ button.classList.add(this.options.activeClass);
206
+ this.currentFilters.add(filterValue);
205
207
  }
208
+ }
206
209
 
207
- /**
208
- * Apply current filters to items
209
- * @public
210
- */
211
- filter() {
212
- this.visibleItems.clear(); // Start with an empty set
213
-
210
+ /**
211
+ * Apply current filters to items
212
+ * @public
213
+ */
214
+ /**
215
+ * Apply current filters to items
216
+ * @public
217
+ */
218
+ filter() {
219
+ // Store the original filter logic
220
+ const standardFilter = () => {
221
+ this.visibleItems.clear();
214
222
  this.items.forEach(item => {
215
223
  if (this.currentFilters.has("*")) {
216
224
  this.showItem(item);
217
- this.visibleItems.add(item); // Add visible item to the set
225
+ this.visibleItems.add(item);
218
226
  } else {
219
227
  const itemCategories = new Set(item.dataset.categories?.split(" ") || []);
220
228
  const matchesFilter = this.options.filterMode === "OR" ? this.matchesAnyFilter(itemCategories) : this.matchesAllFilters(itemCategories);
221
229
  if (matchesFilter) {
222
230
  this.showItem(item);
223
- this.visibleItems.add(item); // Add visible item to the set
231
+ this.visibleItems.add(item);
224
232
  } else {
225
233
  this.hideItem(item);
226
234
  }
227
235
  }
228
236
  });
229
- setTimeout(() => {
230
- this.updateCounter();
231
- }, this.options.animationDuration);
232
- }
237
+ };
233
238
 
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}`);
239
+ // Check if we should use group filtering or standard filtering
240
+ if (this.filterGroups.size === 0) {
241
+ standardFilter();
242
+ } else {
243
+ this.visibleItems.clear();
244
+ this.items.forEach(item => {
245
+ if (this.currentFilters.has("*")) {
246
+ this.showItem(item);
247
+ this.visibleItems.add(item);
248
+ } else {
249
+ const itemCategories = new Set(item.dataset.categories?.split(" ") || []);
250
+ const matchesGroups = this.matchesFilterGroups(itemCategories);
251
+ if (matchesGroups) {
252
+ this.showItem(item);
253
+ this.visibleItems.add(item);
254
+ } else {
255
+ this.hideItem(item);
256
+ }
257
+ }
244
258
  });
245
259
  }
260
+ setTimeout(() => {
261
+ this.updateCounter();
262
+ }, this.options.animationDuration);
263
+ }
246
264
 
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
- }
265
+ /**
266
+ * Add or update a filter group
267
+ * @public
268
+ * @param {string} groupId - Group identifier
269
+ * @param {string[]} filters - Array of filter values
270
+ * @param {string} [operator='OR'] - Operator within group ('AND' or 'OR')
271
+ * @returns {boolean} Success status
272
+ */
273
+ addFilterGroup(groupId, filters) {
274
+ let operator = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "OR";
275
+ try {
276
+ // Validate inputs
277
+ if (!groupId || !Array.isArray(filters)) {
278
+ console.warn("Invalid group parameters");
279
+ return false;
280
+ }
281
+ const validOperator = operator.toUpperCase();
282
+ if (!["AND", "OR"].includes(validOperator)) {
283
+ console.warn('Invalid operator. Using default "OR"');
284
+ operator = "OR";
285
+ }
259
286
 
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)";
287
+ // Create or update group
288
+ this.filterGroups.set(groupId, {
289
+ filters: new Set(filters),
290
+ operator: validOperator
274
291
  });
292
+
293
+ // Only update if we have active groups
294
+ if (this.filterGroups.size > 0) {
295
+ this.updateFiltersFromGroups();
296
+ this.filter();
297
+ this.updateURL();
298
+ }
299
+ return true;
300
+ } catch (error) {
301
+ console.error("Error adding filter group:", error);
302
+ return false;
275
303
  }
304
+ }
276
305
 
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);
306
+ /**
307
+ * Set how groups combine with each other
308
+ * @public
309
+ * @param {string} mode - Mode for combining groups ('AND' or 'OR')
310
+ */
311
+ setGroupMode(mode) {
312
+ const validMode = mode.toUpperCase();
313
+ if (["AND", "OR"].includes(validMode)) {
314
+ this.groupMode = validMode;
315
+ if (this.filterGroups.size > 0) {
316
+ this.filter();
317
+ }
291
318
  }
319
+ }
292
320
 
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
- });
321
+ /**
322
+ * Remove a filter group
323
+ * @public
324
+ * @param {string} groupId - Group identifier
325
+ * @returns {boolean} Success status
326
+ */
327
+ removeFilterGroup(groupId) {
328
+ if (this.filterGroups.has(groupId)) {
329
+ this.filterGroups.delete(groupId);
330
+
331
+ // If no groups left, revert to normal filtering
332
+ if (this.filterGroups.size === 0) {
333
+ this.resetFilters();
334
+ } else {
335
+ this.updateFiltersFromGroups();
336
+ }
337
+ this.filter();
309
338
  this.updateURL();
310
- setTimeout(() => {
311
- this.updateCounter();
312
- }, this.options.animationDuration);
339
+ return true;
313
340
  }
341
+ return false;
342
+ }
314
343
 
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));
344
+ /**
345
+ * Update filters based on groups
346
+ * @private
347
+ */
348
+ updateFiltersFromGroups() {
349
+ // Only process if we have groups
350
+ if (this.filterGroups.size === 0) return;
351
+
352
+ // Clear current filters except '*'
353
+ if (!this.currentFilters.has("*")) {
354
+ this.currentFilters.clear();
332
355
  }
333
356
 
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);
357
+ // Combine all group filters
358
+ for (const group of this.filterGroups.values()) {
359
+ group.filters.forEach(filter => {
360
+ if (filter !== "*") {
361
+ this.currentFilters.add(filter);
349
362
  }
350
363
  });
351
- setTimeout(() => {
352
- this.updateCounter();
353
- }, this.options.animationDuration);
354
364
  }
365
+ }
355
366
 
356
- /**
357
- * Update URL with current filter state
358
- * @private
359
- */
360
- updateURL() {
361
- const params = new URLSearchParams();
367
+ /**
368
+ * Check if item matches any active filter (OR mode)
369
+ * @private
370
+ * @param {Set} itemCategories - Item's categories
371
+ * @returns {boolean} Whether item matches any filter
372
+ */
373
+ matchesAnyFilter(itemCategories) {
374
+ return [...this.currentFilters].some(filter => {
375
+ const [type, value] = filter.split(":");
376
+ return itemCategories.has(`${type}:${value}`);
377
+ });
378
+ }
362
379
 
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
- }
380
+ /**
381
+ * Check if item matches all active filters (AND mode)
382
+ * @private
383
+ * @param {Set} itemCategories - Item's categories
384
+ * @returns {boolean} Whether item matches all filters
385
+ */
386
+ matchesAllFilters(itemCategories) {
387
+ return [...this.currentFilters].every(filter => {
388
+ const [type, value] = filter.split(":");
389
+ return itemCategories.has(`${type}:${value}`);
390
+ });
391
+ }
374
392
 
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);
393
+ /**
394
+ * Show an item with animation
395
+ * @private
396
+ * @param {HTMLElement} item - Item to show
397
+ */
398
+ showItem(item) {
399
+ this.visibleItems.add(item);
400
+ item.classList.remove(this.options.hiddenClass);
401
+ item.style.opacity = "0";
402
+ item.style.transform = "scale(0.95)";
403
+ item.offsetHeight;
404
+ requestAnimationFrame(() => {
405
+ item.style.opacity = "1";
406
+ item.style.transform = "scale(1)";
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Hide an item with animation
412
+ * @private
413
+ * @param {HTMLElement} item - Item to hide
414
+ */
415
+ hideItem(item) {
416
+ item.style.opacity = "0";
417
+ item.style.transform = "scale(0.95)";
418
+ setTimeout(() => {
419
+ if (item.style.opacity === "0") {
420
+ item.classList.add(this.options.hiddenClass);
421
+ this.visibleItems.delete(item);
382
422
  }
383
- const newURL = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
384
- window.history.pushState({}, "", newURL);
385
- }
423
+ }, this.options.animationDuration);
424
+ }
386
425
 
387
- /**
388
- * Load filter state from URL
389
- * @private
390
- */
391
- loadFromURL() {
392
- const params = new URLSearchParams(window.location.search);
393
- this.currentFilters.clear();
426
+ /**
427
+ * Search items by text
428
+ * @public
429
+ * @param {string} query - Search query
430
+ */
431
+ search(query) {
432
+ this.currentSearch = query.toLowerCase().trim();
433
+ this.items.forEach(item => {
434
+ const searchText = this.options.searchKeys.map(key => item.dataset[key] || "").join(" ").toLowerCase();
435
+ const matchesSearch = this.currentSearch === "" || searchText.includes(this.currentSearch);
436
+ if (matchesSearch) {
437
+ this.showItem(item);
438
+ } else {
439
+ this.hideItem(item);
440
+ }
441
+ });
442
+ this.updateURL();
443
+ setTimeout(() => {
444
+ this.updateCounter();
445
+ }, this.options.animationDuration);
446
+ }
394
447
 
395
- // Si aucun filtre n'est présent, utiliser '*'
396
- let hasFilters = false;
448
+ /**
449
+ * Sort items by multiple criteria
450
+ * @public
451
+ * @param {Array<{key: string, direction: string}>} criteria - Sort criteria
452
+ */
453
+ sortMultiple(criteria) {
454
+ const items = [...this.items];
455
+ items.sort((a, b) => {
456
+ for (const criterion of criteria) {
457
+ const valueA = a.dataset[criterion.key];
458
+ const valueB = b.dataset[criterion.key];
459
+ const comparison = criterion.direction === "asc" ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
460
+ if (comparison !== 0) return comparison;
461
+ }
462
+ return 0;
463
+ });
464
+ items.forEach(item => this.container.appendChild(item));
465
+ }
397
466
 
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
- }
467
+ /**
468
+ * Filter items by numeric range
469
+ * @public
470
+ * @param {string} key - Data attribute key
471
+ * @param {number} min - Minimum value
472
+ * @param {number} max - Maximum value
473
+ */
474
+ addRangeFilter(key, min, max) {
475
+ this.items.forEach(item => {
476
+ const value = parseFloat(item.dataset[key]);
477
+ const inRange = value >= min && value <= max;
478
+ if (inRange) {
479
+ this.showItem(item);
480
+ } else {
481
+ this.hideItem(item);
406
482
  }
407
- if (!hasFilters) {
408
- this.currentFilters.add("*");
483
+ });
484
+ setTimeout(() => {
485
+ this.updateCounter();
486
+ }, this.options.animationDuration);
487
+ }
488
+
489
+ /**
490
+ * Check if item matches filter groups
491
+ * @private
492
+ * @param {Set} itemCategories - Item's categories
493
+ * @returns {boolean} Whether item matches the group filters
494
+ */
495
+ matchesFilterGroups(itemCategories) {
496
+ const groupMatches = [...this.filterGroups.values()].map(group => {
497
+ const groupFilters = [...group.filters];
498
+ if (groupFilters.length === 0) return true;
499
+ return group.operator === "OR" ? groupFilters.some(filter => itemCategories.has(filter)) : groupFilters.every(filter => itemCategories.has(filter));
500
+ });
501
+ return this.groupMode === "OR" ? groupMatches.some(matches => matches) : groupMatches.every(matches => matches);
502
+ }
503
+
504
+ /**
505
+ * Update URL with current filter state
506
+ * @private
507
+ */
508
+ updateURL() {
509
+ const params = new URLSearchParams(window.location.search);
510
+
511
+ // Add groups to URL if they exist
512
+ if (this.filterGroups.size > 0) {
513
+ for (const [groupId, group] of this.filterGroups.entries()) {
514
+ params.set(`group_${groupId}`, [...group.filters].join(","));
515
+ params.set(`groupOp_${groupId}`, group.operator.toLowerCase());
409
516
  }
517
+ params.set("groupMode", this.groupMode.toLowerCase());
518
+ }
410
519
 
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);
520
+ // Séparer les filtres par type
521
+ const filtersByType = {};
522
+ for (const filter of this.currentFilters) {
523
+ if (filter !== "*") {
524
+ const [type, value] = filter.split(":");
525
+ if (!filtersByType[type]) {
526
+ filtersByType[type] = new Set();
418
527
  }
419
- });
420
-
421
- // Load search
422
- const search = params.get("search") || "";
423
- if (this.searchInput) {
424
- this.searchInput.value = search;
528
+ filtersByType[type].add(value);
425
529
  }
426
- this.filter();
427
- if (search) {
428
- this.search(search);
530
+ }
531
+
532
+ // Ajouter chaque type de filtre à l'URL
533
+ Object.entries(filtersByType).forEach(_ref => {
534
+ let [type, values] = _ref;
535
+ params.set(type, Array.from(values).join(","));
536
+ });
537
+ if (this.currentSearch) {
538
+ params.set("search", this.currentSearch);
539
+ }
540
+ const newURL = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
541
+ window.history.pushState({}, "", newURL);
542
+ }
543
+
544
+ /**
545
+ * Load filter state from URL
546
+ * @private
547
+ */
548
+ loadFromURL() {
549
+ const params = new URLSearchParams(window.location.search);
550
+
551
+ // Load groups if they exist
552
+ this.filterGroups.clear();
553
+ for (const [key, value] of params.entries()) {
554
+ if (key.startsWith("group_")) {
555
+ const groupId = key.replace("group_", "");
556
+ const operator = params.get(`groupOp_${groupId}`)?.toUpperCase() || "OR";
557
+ const filters = value.split(",");
558
+ this.addFilterGroup(groupId, filters, operator);
429
559
  }
430
560
  }
431
561
 
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}`;
562
+ // Set group mode if present
563
+ const groupMode = params.get("groupMode")?.toUpperCase();
564
+ if (groupMode && ["AND", "OR"].includes(groupMode)) {
565
+ this.groupMode = groupMode;
566
+ }
567
+ this.currentFilters.clear();
568
+
569
+ // Si aucun filtre n'est présent, utiliser '*'
570
+ let hasFilters = false;
571
+
572
+ // Parcourir tous les paramètres
573
+ for (const [type, values] of params.entries()) {
574
+ if (type !== "search") {
575
+ hasFilters = true;
576
+ values.split(",").forEach(value => {
577
+ this.currentFilters.add(`${type}:${value}`);
578
+ });
442
579
  }
443
- return {
444
- total,
445
- visible
446
- };
580
+ }
581
+ if (!hasFilters) {
582
+ this.currentFilters.add("*");
447
583
  }
448
584
 
449
- /**
450
- * Change filter mode
451
- * @public
452
- * @param {string} mode - New filter mode ('OR' or 'AND')
453
- */
454
- setFilterMode(mode) {
455
- if (["OR", "AND"].includes(mode.toUpperCase())) {
456
- this.options.filterMode = mode.toUpperCase();
457
- this.filter();
585
+ // Update active buttons
586
+ this.filterButtons.forEach(button => {
587
+ const filterValue = button.dataset.filter;
588
+ if (this.currentFilters.has(filterValue) || filterValue === "*" && this.currentFilters.has("*")) {
589
+ button.classList.add(this.options.activeClass);
590
+ } else {
591
+ button.classList.remove(this.options.activeClass);
458
592
  }
593
+ });
594
+
595
+ // Load search
596
+ const search = params.get("search") || "";
597
+ if (this.searchInput) {
598
+ this.searchInput.value = search;
459
599
  }
600
+ this.filter();
601
+ if (search) {
602
+ this.search(search);
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Update results counter
608
+ * @private
609
+ * @returns {{total: number, visible: number}}
610
+ */
611
+ updateCounter() {
612
+ const total = this.items.length;
613
+ const visible = this.visibleItems.size;
614
+ if (this.counter) {
615
+ this.counter.textContent = `Showing ${visible} of ${total}`;
616
+ }
617
+ return {
618
+ total,
619
+ visible
620
+ };
621
+ }
622
+
623
+ /**
624
+ * Set animation options
625
+ * @public
626
+ * @param {Object} options - Animation options
627
+ */
628
+ setAnimationOptions() {
629
+ let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
630
+ this.options.animationDuration = options.duration || this.options.animationDuration;
631
+ this.options.animationType = options.type || "ease-out";
632
+ this.addStyles(); // Refresh styles with new options
633
+ }
634
+
635
+ /**
636
+ * Event handling system
637
+ */
638
+ addEventSystem() {
639
+ this.events = {};
640
+ this.on = (eventName, callback) => {
641
+ if (!this.events[eventName]) {
642
+ this.events[eventName] = [];
643
+ }
644
+ this.events[eventName].push(callback);
645
+ };
646
+ this.emit = (eventName, data) => {
647
+ if (this.events[eventName]) {
648
+ this.events[eventName].forEach(callback => callback(data));
649
+ }
650
+ };
651
+ }
652
+
653
+ /**
654
+ * Add pagination
655
+ * @public
656
+ * @param {number} itemsPerPage - Number of items per page
657
+ */
658
+ setPagination(itemsPerPage) {
659
+ this.pagination = {
660
+ currentPage: 1,
661
+ itemsPerPage: itemsPerPage,
662
+ totalPages: Math.ceil(this.visibleItems.size / itemsPerPage)
663
+ };
664
+ this.updatePagination();
665
+ }
666
+ updatePagination() {
667
+ const start = (this.pagination.currentPage - 1) * this.pagination.itemsPerPage;
668
+ const end = start + this.pagination.itemsPerPage;
669
+ [...this.visibleItems].forEach((item, index) => {
670
+ if (index >= start && index < end) {
671
+ this.showItem(item);
672
+ } else {
673
+ this.hideItem(item);
674
+ }
675
+ });
676
+ }
677
+
678
+ /**
679
+ * Enable analytics tracking
680
+ * @public
681
+ * @param {Function} callback - Analytics callback function
682
+ */
683
+ enableAnalytics(callback) {
684
+ this.analyticsCallback = callback;
685
+ this.on("filter", data => {
686
+ this.analyticsCallback({
687
+ type: "filter",
688
+ filters: [...this.currentFilters],
689
+ visibleItems: this.visibleItems.size,
690
+ timestamp: new Date().toISOString()
691
+ });
692
+ });
693
+ }
460
694
 
461
- /**
462
- * Add filter by type and value
463
- * @public
464
- * @param {string} type - Filter type
465
- * @param {string} value - Filter value
466
- */
467
- addFilter(type, value) {
468
- this.currentFilters.add(`${type}:${value}`);
695
+ /**
696
+ * Sort with custom comparator
697
+ * @public
698
+ * @param {string} key - Data attribute key
699
+ * @param {Function} comparator - Custom comparison function
700
+ */
701
+ sortWithComparator(key, comparator) {
702
+ const items = [...this.items];
703
+ items.sort((a, b) => {
704
+ const valueA = a.dataset[key];
705
+ const valueB = b.dataset[key];
706
+ return comparator(valueA, valueB);
707
+ });
708
+ items.forEach(item => this.container.appendChild(item));
709
+ }
710
+
711
+ /**
712
+ * Add responsive behavior
713
+ * @public
714
+ * @param {Object} breakpoints - Breakpoint configurations
715
+ */
716
+ setResponsiveOptions(breakpoints) {
717
+ window.addEventListener("resize", debounce(() => {
718
+ const width = window.innerWidth;
719
+ for (const [breakpoint, options] of Object.entries(breakpoints)) {
720
+ if (width <= parseInt(breakpoint)) {
721
+ Object.assign(this.options, options);
722
+ this.filter();
723
+ break;
724
+ }
725
+ }
726
+ }, 250));
727
+ }
728
+
729
+ /**
730
+ * Enable keyboard navigation
731
+ * @public
732
+ */
733
+ enableKeyboardNavigation() {
734
+ document.addEventListener("keydown", e => {
735
+ if (e.key === "Enter" && document.activeElement.classList.contains(this.options.filterButtonSelector.slice(1))) {
736
+ document.activeElement.click();
737
+ }
738
+ });
739
+ }
740
+
741
+ /**
742
+ * Export current filter state
743
+ * @public
744
+ * @returns {Object} Filter state
745
+ */
746
+ exportState() {
747
+ return {
748
+ filters: [...this.currentFilters],
749
+ search: this.currentSearch,
750
+ mode: this.options.filterMode,
751
+ timestamp: new Date().toISOString()
752
+ };
753
+ }
754
+
755
+ /**
756
+ * Import filter state
757
+ * @public
758
+ * @param {Object} state - Filter state to import
759
+ */
760
+ importState(state) {
761
+ if (state.filters) {
762
+ this.currentFilters = new Set(state.filters);
763
+ this.currentSearch = state.search || "";
764
+ this.options.filterMode = state.mode || "OR";
469
765
  this.filter();
470
766
  this.updateURL();
471
767
  }
768
+ }
472
769
 
473
- /**
474
- * Remove filter by type and value
475
- * @public
476
- * @param {string} type - Filter type
477
- */
478
- removeFilter(type, value) {
479
- this.currentFilters.delete(`${type}:${value}`);
480
- if (this.currentFilters.size === 0) {
481
- this.currentFilters.add("*");
482
- }
770
+ /**
771
+ * Save current filter state as preset
772
+ * @public
773
+ * @param {string} presetName - Name of the preset
774
+ */
775
+ savePreset(presetName) {
776
+ const preset = {
777
+ filters: [...this.currentFilters],
778
+ search: this.currentSearch,
779
+ mode: this.options.filterMode
780
+ };
781
+ localStorage.setItem(`afs_preset_${presetName}`, JSON.stringify(preset));
782
+ }
783
+
784
+ /**
785
+ * Load filter preset
786
+ * @public
787
+ * @param {string} presetName - Name of the preset to load
788
+ */
789
+ loadPreset(presetName) {
790
+ const preset = JSON.parse(localStorage.getItem(`afs_preset_${presetName}`));
791
+ if (preset) {
792
+ this.currentFilters = new Set(preset.filters);
793
+ this.currentSearch = preset.search;
794
+ this.options.filterMode = preset.mode;
483
795
  this.filter();
484
796
  this.updateURL();
485
797
  }
798
+ }
486
799
 
487
- /**
488
- * Get active filters by type
489
- * @public
490
- * @param {string} type - Filter type
491
- */
492
- getActiveFiltersByType(type) {
493
- return [...this.currentFilters].filter(filter => filter.startsWith(`${type}:`)).map(filter => filter.split(":")[1]);
800
+ /**
801
+ * Set filter logic mode (alias for setFilterMode)
802
+ * @public
803
+ * @param {string} logic - New filter logic ('AND' or 'OR')
804
+ */
805
+ setLogic(logic) {
806
+ if (typeof logic === "boolean") {
807
+ // Handle boolean input (true = AND, false = OR)
808
+ this.options.filterMode = logic ? "AND" : "OR";
809
+ this.filter();
810
+ return;
811
+ }
812
+ const mode = logic.toUpperCase();
813
+ if (["OR", "AND"].includes(mode)) {
814
+ this.options.filterMode = mode;
815
+ this.filter();
494
816
  }
495
817
  }
496
818
 
497
- exports.AFS = AFS;
819
+ /**
820
+ * Change filter mode
821
+ * @public
822
+ * @param {string} mode - New filter mode ('OR' or 'AND')
823
+ */
824
+ setFilterMode(mode) {
825
+ if (["OR", "AND"].includes(mode.toUpperCase())) {
826
+ this.options.filterMode = mode.toUpperCase();
827
+ this.filter();
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Add filter by type and value
833
+ * @public
834
+ * @param {string} type - Filter type
835
+ * @param {string} value - Filter value
836
+ */
837
+ addFilter(type, value) {
838
+ this.currentFilters.add(`${type}:${value}`);
839
+ this.filter();
840
+ this.updateURL();
841
+ }
842
+
843
+ /**
844
+ * Remove filter by type and value
845
+ * @public
846
+ * @param {string} type - Filter type
847
+ */
848
+ removeFilter(type, value) {
849
+ this.currentFilters.delete(`${type}:${value}`);
850
+ if (this.currentFilters.size === 0) {
851
+ this.currentFilters.add("*");
852
+ }
853
+ this.filter();
854
+ this.updateURL();
855
+ }
856
+
857
+ /**
858
+ * Get active filters by type
859
+ * @public
860
+ * @param {string} type - Filter type
861
+ */
862
+ getActiveFiltersByType(type) {
863
+ return [...this.currentFilters].filter(filter => filter.startsWith(`${type}:`)).map(filter => filter.split(":")[1]);
864
+ }
865
+ }
498
866
 
499
- Object.defineProperty(exports, '__esModule', { value: true });
867
+ exports.AFS = AFS;
500
868
 
501
869
  }));
502
870
  //# sourceMappingURL=AFS.js.map