@braudypedrosa/bp-listings 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  Airbnb-style listings + map widget built with vanilla JavaScript and CSS.
4
4
 
5
- Current version: **1.0.1**
5
+ Current version: **1.0.6**
6
6
 
7
7
  ## Overview
8
8
 
9
9
  `bp-listings` renders a listings grid and interactive map in one widget container.
10
10
  It is framework-agnostic, distributed as standalone JS/CSS, and uses Leaflet for map rendering.
11
11
 
12
+ `bp-listings` does not bundle a search UI. Search is integrated through the consumer-controlled
13
+ `renderSearchSlot` hook, which lets you mount your own widget above the listings grid and drive
14
+ result updates through `setListings()`.
15
+
12
16
  ## Features
13
17
 
14
18
  - Standalone UMD distribution (`ListingsMap.init(...)`)
@@ -20,7 +24,7 @@ It is framework-agnostic, distributed as standalone JS/CSS, and uses Leaflet for
20
24
  - Optional pagination with configurable page size
21
25
  - Show/hide map toggle and fullscreen map action
22
26
  - Marker-to-card and card-to-marker highlighting behavior
23
- - Consumer-controlled search slot renderer
27
+ - Consumer-controlled search slot renderer with optional cleanup
24
28
  - Runtime methods to update listings and navigate map/pagination state
25
29
 
26
30
  ## Installation
@@ -77,11 +81,11 @@ import '@braudypedrosa/bp-listings/styles';
77
81
 
78
82
  Each listing object can include:
79
83
 
80
- - `id: string` (required)
81
- - `title: string` (required)
82
- - `price: number | string` (required)
83
- - `lat: number` (required)
84
- - `lng: number` (required)
84
+ - `id: string` required
85
+ - `title: string` required
86
+ - `price: number | string` required
87
+ - `lat: number` required
88
+ - `lng: number` required
85
89
  - `subtitle?: string`
86
90
  - `details?: string`
87
91
  - `dates?: string`
@@ -89,33 +93,286 @@ Each listing object can include:
89
93
  - `tag?: string`
90
94
  - `rating?: number`
91
95
  - `reviewCount?: number`
92
- - `badge?: string` (e.g. `Guest favorite`, `Superhost`)
96
+ - `badge?: string` for labels such as `Guest favorite` or `Superhost`
93
97
  - `images?: string[]`
94
98
  - `favorited?: boolean`
95
99
 
100
+ ### Recommended Consumer-side `searchData`
101
+
102
+ When you integrate external search through `renderSearchSlot`, the recommended consumer-side shape is:
103
+
104
+ ```js
105
+ {
106
+ searchData: {
107
+ location?: string | string[],
108
+ availability?: Array<{ start: string, end: string }>,
109
+ fields?: Record<string, string | string[]>,
110
+ filters?: Record<string, string | string[] | number>
111
+ }
112
+ }
113
+ ```
114
+
115
+ Important:
116
+
117
+ - this is a docs/demo convention only
118
+ - `bp-listings` does not parse or enforce `searchData`
119
+ - matching remains consumer-owned
120
+
96
121
  ## Config Options
97
122
 
98
- - `container: HTMLElement | string` (required)
123
+ - `container: HTMLElement | string` required
99
124
  - `listings: Array<Listing>` default `[]`
100
125
  - `currency: string` default `'$'`
101
126
  - `mapOptions.center: [number, number]` default `[14.55, 121.03]`
102
127
  - `mapOptions.zoom: number` default `12`
103
- - `tileUrl: string` (OpenStreetMap by default)
128
+ - `tileUrl: string` OpenStreetMap by default
104
129
  - `tileAttribution: string`
105
130
  - `showMapToggle: boolean` default `true`
106
131
  - `showSort: boolean` default `true`
107
132
  - `showPagination: boolean` default `true`
108
- - `pageSize: number` default `0` (`0` disables paging)
109
- - `renderSearchSlot: (containerEl: HTMLElement) => void`
133
+ - `pageSize: number` default `12`; explicit `0` or any non-positive value disables paging
134
+ - `renderSearchSlot: (containerEl: HTMLElement, listingsWidget: ListingsMapWidget) => void | (() => void)`
110
135
  - `onFavorite: (listing, isFavorited) => void`
111
136
  - `onListingClick: (listing) => void`
112
137
  - `onMapMoveEnd: ({bounds, center, zoom}) => void`
113
138
 
139
+ `renderSearchSlot` rules:
140
+
141
+ - `containerEl` is the mounted slot host rendered above the listings grid
142
+ - `listingsWidget` is the active `bp-listings` instance
143
+ - if the callback returns a function, `bp-listings` calls it during `destroy()`
144
+ - legacy usage that only accepts `(containerEl)` still works
145
+
146
+ ## Using `bp-search-widget` with `renderSearchSlot`
147
+
148
+ This is the official integration model:
149
+
150
+ - keep `bp-listings` focused on rendering cards, markers, sorting, and pagination
151
+ - mount `BPSearchWidget` inside `renderSearchSlot`
152
+ - keep the full `allListings` dataset outside the widget
153
+ - filter externally on `BPSearchWidget.onSearch`
154
+ - push the filtered subset back into `bp-listings` with `setListings(filteredListings)`
155
+
156
+ This pattern requires a bundler or dev server that can resolve npm packages and SCSS style imports.
157
+
158
+ ```js
159
+ import '@braudypedrosa/bp-listings';
160
+ import '@braudypedrosa/bp-listings/styles';
161
+ import '@braudypedrosa/bp-calendar/styles';
162
+ import '@braudypedrosa/bp-search-widget/styles';
163
+ import { BPSearchWidget } from '@braudypedrosa/bp-search-widget';
164
+
165
+ const allListings = [
166
+ {
167
+ id: 'villa-ocean',
168
+ title: 'Ocean-view villa in Batangas',
169
+ price: 21450,
170
+ lat: 13.7565,
171
+ lng: 120.9414,
172
+ images: ['villa-1.jpg'],
173
+ searchData: {
174
+ location: ['Batangas', 'Nasugbu', 'Beachfront'],
175
+ availability: [{ start: '2030-04-10', end: '2030-04-30' }],
176
+ fields: {
177
+ 'bp-guests': '6',
178
+ },
179
+ filters: {
180
+ 'bp-bedrooms': 4,
181
+ 'bp-view': 'Ocean',
182
+ 'bp-amenities': ['Pool', 'Spa'],
183
+ },
184
+ },
185
+ },
186
+ {
187
+ id: 'cabin-ridge',
188
+ title: 'Ridge cabin outside Tagaytay',
189
+ price: 11800,
190
+ lat: 14.1153,
191
+ lng: 120.9625,
192
+ images: ['cabin-1.jpg'],
193
+ searchData: {
194
+ location: ['Tagaytay', 'Ridge'],
195
+ availability: [{ start: '2030-04-01', end: '2030-04-18' }],
196
+ fields: {
197
+ 'bp-guests': '4',
198
+ },
199
+ filters: {
200
+ 'bp-bedrooms': 2,
201
+ 'bp-view': 'Garden',
202
+ 'bp-amenities': ['Pet Friendly'],
203
+ },
204
+ },
205
+ },
206
+ ];
207
+
208
+ const fieldDefinitions = [
209
+ {
210
+ key: 'bp-guests',
211
+ label: 'Guests',
212
+ type: 'select',
213
+ options: ['2', '4', '6'],
214
+ position: 'end',
215
+ },
216
+ ];
217
+
218
+ const filterDefinitions = [
219
+ {
220
+ key: 'bp-bedrooms',
221
+ label: 'Bedrooms',
222
+ type: 'counter',
223
+ min: 0,
224
+ max: 8,
225
+ defaultValue: 0,
226
+ width: '24%',
227
+ },
228
+ {
229
+ key: 'bp-view',
230
+ label: 'View',
231
+ type: 'select',
232
+ options: ['Ocean', 'Garden', 'City'],
233
+ width: '28%',
234
+ },
235
+ {
236
+ key: 'bp-amenities',
237
+ label: 'Amenities',
238
+ type: 'checkbox',
239
+ options: ['Pool', 'Spa', 'Gym', 'Pet Friendly'],
240
+ width: '48%',
241
+ },
242
+ ];
243
+
244
+ const fieldTypeMap = new Map(
245
+ [...fieldDefinitions, ...filterDefinitions].map((field) => [field.key, field.type])
246
+ );
247
+
248
+ const normalizeString = (value) => {
249
+ if (value === null || value === undefined) return '';
250
+ return String(value).trim().toLowerCase();
251
+ };
252
+
253
+ const toArray = (value) => {
254
+ if (Array.isArray(value)) return value;
255
+ if (value === null || value === undefined) return [];
256
+ return [value];
257
+ };
258
+
259
+ const normalizeStringArray = (value) =>
260
+ toArray(value).map((entry) => normalizeString(entry)).filter(Boolean);
261
+
262
+ const matchesSubstring = (haystackValue, needleValue) => {
263
+ const needle = normalizeString(needleValue);
264
+ if (!needle) return true;
265
+ return normalizeStringArray(haystackValue).some((value) => value.includes(needle));
266
+ };
267
+
268
+ const matchesExact = (haystackValue, needleValue) => {
269
+ const needle = normalizeString(needleValue);
270
+ if (!needle) return true;
271
+ return normalizeStringArray(haystackValue).some((value) => value === needle);
272
+ };
273
+
274
+ const matchesAllChoices = (haystackValue, needleValues) => {
275
+ const selectedValues = normalizeStringArray(needleValues);
276
+ if (selectedValues.length === 0) return true;
277
+ const availableValues = new Set(normalizeStringArray(haystackValue));
278
+ return selectedValues.every((value) => availableValues.has(value));
279
+ };
280
+
281
+ const matchesCounter = (haystackValue, needleValue) => {
282
+ const numericNeedle = Number(needleValue);
283
+ const numericHaystack = Number(haystackValue);
284
+ if (!Number.isFinite(numericNeedle)) return true;
285
+ if (!Number.isFinite(numericHaystack)) return false;
286
+ return numericHaystack >= numericNeedle;
287
+ };
288
+
289
+ const isValidDateString = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
290
+
291
+ const matchesAvailability = (availability, checkIn, checkOut) => {
292
+ if (!Array.isArray(availability) || !isValidDateString(checkIn) || !isValidDateString(checkOut)) {
293
+ return false;
294
+ }
295
+
296
+ return availability.some((windowRange) => (
297
+ windowRange &&
298
+ isValidDateString(windowRange.start) &&
299
+ isValidDateString(windowRange.end) &&
300
+ windowRange.start <= checkIn &&
301
+ windowRange.end >= checkOut
302
+ ));
303
+ };
304
+
305
+ const matchValues = (type, listingValue, submittedValue) => {
306
+ if (type === 'checkbox') return matchesAllChoices(listingValue, submittedValue);
307
+ if (type === 'select' || type === 'radio') return matchesExact(listingValue, submittedValue);
308
+ if (type === 'counter') return matchesCounter(listingValue, submittedValue);
309
+ return matchesSubstring(listingValue, submittedValue);
310
+ };
311
+
312
+ const matchListing = (listing, payload) => {
313
+ const searchData = listing.searchData || {};
314
+ const fieldValues = searchData.fields || {};
315
+ const filterValues = searchData.filters || {};
316
+
317
+ if (!matchesSubstring(searchData.location, payload.location)) {
318
+ return false;
319
+ }
320
+
321
+ if (payload.checkIn && payload.checkOut) {
322
+ if (!matchesAvailability(searchData.availability, payload.checkIn, payload.checkOut)) {
323
+ return false;
324
+ }
325
+ }
326
+
327
+ for (const [key, value] of Object.entries(payload.customFields || {})) {
328
+ if (!matchValues(fieldTypeMap.get(key), fieldValues[key], value)) {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ for (const [key, value] of Object.entries(payload.filters || {})) {
334
+ if (!matchValues(fieldTypeMap.get(key), filterValues[key], value)) {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ return true;
340
+ };
341
+
342
+ const listingsWidget = ListingsMap.init({
343
+ container: '#widget',
344
+ listings: allListings,
345
+ renderSearchSlot: (containerEl, widget) => {
346
+ const searchWidget = new BPSearchWidget(containerEl, {
347
+ fields: fieldDefinitions,
348
+ filters: filterDefinitions,
349
+ calendarOptions: {
350
+ startDate: new Date('2030-03-01T00:00:00'),
351
+ monthsToShow: 2,
352
+ datepickerPlacement: 'auto',
353
+ },
354
+ onSearch: (payload) => {
355
+ const filteredListings = allListings.filter((listing) => matchListing(listing, payload));
356
+ widget.setListings(filteredListings);
357
+ },
358
+ });
359
+
360
+ return () => {
361
+ searchWidget.destroy();
362
+ };
363
+ },
364
+ });
365
+
366
+ document.querySelector('#reset-listings').addEventListener('click', () => {
367
+ listingsWidget.setListings(allListings);
368
+ });
369
+ ```
370
+
114
371
  ## Public Methods
115
372
 
116
373
  Returned widget instance exposes:
117
374
 
118
- - `setListings(listings)`
375
+ - `setListings(listings)` and preserves the current sort selection while resetting pagination to page 1
119
376
  - `panToListing(id)`
120
377
  - `toggleMap()`
121
378
  - `goToPage(pageNumber)`
@@ -126,7 +383,8 @@ Returned widget instance exposes:
126
383
  All classes are scoped with `.lm-*`.
127
384
 
128
385
  You can customize appearance via CSS variables on `.lm-widget`, including:
129
- - typography (`--lm-font`)
386
+
387
+ - typography via `--lm-font`
130
388
  - text and border colors
131
389
  - badge colors
132
390
  - price marker colors
@@ -134,10 +392,28 @@ You can customize appearance via CSS variables on `.lm-widget`, including:
134
392
 
135
393
  The included stylesheet is standalone and does not require a CSS framework.
136
394
 
395
+ When the map is hidden, the desktop listings grid auto-fits to the available width so wider layouts
396
+ can expand beyond the default 2-column split view.
397
+
398
+ ## Local Demo
399
+
400
+ This repo includes a local demo at [index.html](/Users/braudypedorsa/Projects/libraries/bp-listings/index.html).
401
+
402
+ Run it with:
403
+
404
+ ```bash
405
+ npm install
406
+ npm run dev
407
+ ```
408
+
409
+ The local demo uses Vite only for development so it can resolve `@braudypedrosa/bp-search-widget`,
410
+ `@braudypedrosa/bp-calendar`, and their styles. The published `bp-listings` package format is unchanged.
411
+
137
412
  ## Notes
138
413
 
139
414
  - Leaflet is loaded from `unpkg` automatically if it is not already available on the page.
140
415
  - If your app already loads Leaflet, the widget reuses existing `window.L`.
416
+ - `bp-listings` does not include internal search/filter behavior.
141
417
 
142
418
  ## License
143
419