@braudypedrosa/bp-listings 1.0.5 → 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 +290 -14
- package/listings-map.css +242 -326
- package/listings-map.js +118 -97
- package/listings-map.scss +925 -0
- package/package.json +14 -2
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.
|
|
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`
|
|
81
|
-
- `title: string`
|
|
82
|
-
- `price: number | string`
|
|
83
|
-
- `lat: number`
|
|
84
|
-
- `lng: number`
|
|
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`
|
|
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`
|
|
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`
|
|
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 `
|
|
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
|
-
|
|
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
|
|