react-listing-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/chunk-2T5PE5MB.js +2 -0
- package/dist/chunk-2VUHLHHX.cjs +2 -0
- package/dist/chunk-35KYEBAC.js +2 -0
- package/dist/chunk-5FBI2WIM.js +2 -0
- package/dist/chunk-5O36WI7H.cjs +2 -0
- package/dist/chunk-6PZHSHU3.js +2 -0
- package/dist/chunk-CHNUE46K.cjs +2 -0
- package/dist/chunk-LCQZIWBO.cjs +2 -0
- package/dist/chunk-N3OZ2HK2.cjs +2 -0
- package/dist/chunk-PFPHWJAA.js +2 -0
- package/dist/components-provider-CF0Mo0B8.d.cts +120 -0
- package/dist/components-provider-CjMxkxrP.d.ts +120 -0
- package/dist/entity-adapter.interface-BDgbSxhq.d.cts +33 -0
- package/dist/entity-adapter.interface-BDgbSxhq.d.ts +33 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +674 -0
- package/dist/index.d.ts +674 -0
- package/dist/index.js +2 -0
- package/dist/listing-app-De6OqpUC.d.ts +199 -0
- package/dist/listing-app-pKMQBuQI.d.cts +199 -0
- package/dist/listing-config-options.interface-ZJYY_ZvR.d.cts +12 -0
- package/dist/listing-config-options.interface-ZJYY_ZvR.d.ts +12 -0
- package/dist/map-provider.interface-BMnwW3ob.d.cts +37 -0
- package/dist/map-provider.interface-BxexMT3X.d.ts +37 -0
- package/dist/maps/google/index.cjs +2 -0
- package/dist/maps/google/index.d.cts +39 -0
- package/dist/maps/google/index.d.ts +39 -0
- package/dist/maps/google/index.js +2 -0
- package/dist/presets/rental/index.cjs +2 -0
- package/dist/presets/rental/index.d.cts +266 -0
- package/dist/presets/rental/index.d.ts +266 -0
- package/dist/presets/rental/index.js +2 -0
- package/dist/shadcn/index.cjs +2 -0
- package/dist/shadcn/index.d.cts +261 -0
- package/dist/shadcn/index.d.ts +261 -0
- package/dist/shadcn/index.js +2 -0
- package/dist/styled/index.cjs +2 -0
- package/dist/styled/index.d.cts +123 -0
- package/dist/styled/index.d.ts +123 -0
- package/dist/styled/index.js +2 -0
- package/dist/styles.css +972 -0
- package/dist/testing/index.cjs +2 -0
- package/dist/testing/index.d.cts +51 -0
- package/dist/testing/index.d.ts +51 -0
- package/dist/testing/index.js +2 -0
- package/dist/url-sync.controller-CsU_QxoS.d.cts +89 -0
- package/dist/url-sync.controller-DX67JivW.d.ts +89 -0
- package/package.json +143 -0
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); var _class;"use client";
|
|
2
|
+
var o= (_class =class{constructor() { _class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);_class.prototype.__init7.call(this); }__init() {this.mounts=[]}__init2() {this.renderedLayers=[]}__init3() {this.removedLayers=[]}__init4() {this.fitBoundsCalls=[]}__init5() {this.destroyed=[]}__init6() {this.destroyCount=0}__init7() {this.boundsListeners=new Set}mount(t,e){let n={raw:{el:t,opts:e}};return this.mounts.push(n),n}renderLayer(t,e){this.renderedLayers.push(e);let n=!1;return()=>{if(n)return;n=!0;let s=this.renderedLayers.indexOf(e);s!==-1&&(this.renderedLayers.splice(s,1),this.removedLayers.push(e))}}onBoundsChange(t,e){return this.boundsListeners.add(e),()=>{this.boundsListeners.delete(e)}}emitBounds(t){for(let e of this.boundsListeners)e(t)}fitBounds(t,e){this.fitBoundsCalls.push(e)}destroy(t){this.destroyed.push(t),this.destroyCount+=1,this.boundsListeners.clear()}}, _class);var d=class{constructor(t,e,n,s=r=>r.id){this.rows=t;this.predicate=e;this.toLatLng=n;this.idOf=s}async list(t,e){let n=this.rows.filter(u=>this.predicate(u,t)),s=e.cursor==null?0:Number(e.cursor)+1,r=n.slice(s,s+e.limit),i=s+r.length-1,l=i+1<n.length;return{items:r,nextCursor:l?String(i):null,total:n.length}}async getPoints(t,e){let s=this.rows.filter(r=>this.predicate(r,t)).map(r=>({id:this.idOf(r),position:this.toLatLng(r),entity:r}));return e?s.filter(r=>this.isInBounds(r.position,e)):s}async getById(t){let e=this.rows.find(n=>this.idOf(n)===t);if(!e)throw new Error(`InMemoryEntityAdapter: no row with id "${String(t)}"`);return e}isInBounds(t,e){return t.lat<=e.north&&t.lat>=e.south&&t.lng>=e.west&&t.lng<=e.east}};exports.FakeMapProvider = o; exports.InMemoryEntityAdapter = d;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { B as Bounds, a as EntityAdapter, L as LatLng, E as EntityId, b as PageRequest, P as Page, M as MapPoint } from '../entity-adapter.interface-BDgbSxhq.cjs';
|
|
2
|
+
import { M as MapProvider, a as MapHandle, R as RenderedLayer, b as MapInitOptions, U as Unsubscribe } from '../map-provider.interface-BMnwW3ob.cjs';
|
|
3
|
+
|
|
4
|
+
type BoundsListener = (b: Bounds) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Headless in-memory `MapProvider` test double. Records everything it is
|
|
7
|
+
* asked to do (mounts, rendered layers, fitBounds calls, destroys) into
|
|
8
|
+
* public arrays/counters, and exposes `emitBounds()` so a test can simulate
|
|
9
|
+
* a user pan/zoom without a real map SDK or browser.
|
|
10
|
+
*/
|
|
11
|
+
declare class FakeMapProvider implements MapProvider {
|
|
12
|
+
readonly mounts: MapHandle[];
|
|
13
|
+
readonly renderedLayers: RenderedLayer[];
|
|
14
|
+
readonly removedLayers: RenderedLayer[];
|
|
15
|
+
readonly fitBoundsCalls: Bounds[];
|
|
16
|
+
readonly destroyed: MapHandle[];
|
|
17
|
+
destroyCount: number;
|
|
18
|
+
private readonly boundsListeners;
|
|
19
|
+
mount(el: HTMLElement, opts: MapInitOptions): MapHandle;
|
|
20
|
+
renderLayer(_handle: MapHandle, layer: RenderedLayer): Unsubscribe;
|
|
21
|
+
onBoundsChange(_handle: MapHandle, cb: BoundsListener): Unsubscribe;
|
|
22
|
+
/** Test-only: simulates a user pan/zoom by invoking every registered bounds listener. */
|
|
23
|
+
emitBounds(b: Bounds): void;
|
|
24
|
+
fitBounds(_handle: MapHandle, b: Bounds): void;
|
|
25
|
+
destroy(handle: MapHandle): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* In-memory `EntityAdapter` test double. `list()` filters `rows` through
|
|
30
|
+
* `predicate` and paginates by array index: `cursor` is the (stringified)
|
|
31
|
+
* index of the last row already served (`null` means start from the
|
|
32
|
+
* beginning), and the returned `nextCursor` is that same kind of value for
|
|
33
|
+
* the next call — `null` once nothing is left. `getPoints()` runs the same
|
|
34
|
+
* predicate filter and projects each surviving row through `toLatLng`
|
|
35
|
+
* (further narrowed to `bounds` when one is given). `idOf` defaults to
|
|
36
|
+
* reading `row.id`, mirroring most real adapters, but can be overridden for
|
|
37
|
+
* rows keyed by something else.
|
|
38
|
+
*/
|
|
39
|
+
declare class InMemoryEntityAdapter<TEntity, TFilters> implements EntityAdapter<TEntity, TFilters> {
|
|
40
|
+
private readonly rows;
|
|
41
|
+
private readonly predicate;
|
|
42
|
+
private readonly toLatLng;
|
|
43
|
+
private readonly idOf;
|
|
44
|
+
constructor(rows: TEntity[], predicate: (row: TEntity, filters: TFilters) => boolean, toLatLng: (row: TEntity) => LatLng, idOf?: (row: TEntity) => EntityId);
|
|
45
|
+
list(filters: TFilters, page: PageRequest): Promise<Page<TEntity>>;
|
|
46
|
+
getPoints(filters: TFilters, bounds?: Bounds): Promise<MapPoint<TEntity>[]>;
|
|
47
|
+
getById(id: EntityId): Promise<TEntity>;
|
|
48
|
+
private isInBounds;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { FakeMapProvider, InMemoryEntityAdapter };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { B as Bounds, a as EntityAdapter, L as LatLng, E as EntityId, b as PageRequest, P as Page, M as MapPoint } from '../entity-adapter.interface-BDgbSxhq.js';
|
|
2
|
+
import { M as MapProvider, a as MapHandle, R as RenderedLayer, b as MapInitOptions, U as Unsubscribe } from '../map-provider.interface-BxexMT3X.js';
|
|
3
|
+
|
|
4
|
+
type BoundsListener = (b: Bounds) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Headless in-memory `MapProvider` test double. Records everything it is
|
|
7
|
+
* asked to do (mounts, rendered layers, fitBounds calls, destroys) into
|
|
8
|
+
* public arrays/counters, and exposes `emitBounds()` so a test can simulate
|
|
9
|
+
* a user pan/zoom without a real map SDK or browser.
|
|
10
|
+
*/
|
|
11
|
+
declare class FakeMapProvider implements MapProvider {
|
|
12
|
+
readonly mounts: MapHandle[];
|
|
13
|
+
readonly renderedLayers: RenderedLayer[];
|
|
14
|
+
readonly removedLayers: RenderedLayer[];
|
|
15
|
+
readonly fitBoundsCalls: Bounds[];
|
|
16
|
+
readonly destroyed: MapHandle[];
|
|
17
|
+
destroyCount: number;
|
|
18
|
+
private readonly boundsListeners;
|
|
19
|
+
mount(el: HTMLElement, opts: MapInitOptions): MapHandle;
|
|
20
|
+
renderLayer(_handle: MapHandle, layer: RenderedLayer): Unsubscribe;
|
|
21
|
+
onBoundsChange(_handle: MapHandle, cb: BoundsListener): Unsubscribe;
|
|
22
|
+
/** Test-only: simulates a user pan/zoom by invoking every registered bounds listener. */
|
|
23
|
+
emitBounds(b: Bounds): void;
|
|
24
|
+
fitBounds(_handle: MapHandle, b: Bounds): void;
|
|
25
|
+
destroy(handle: MapHandle): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* In-memory `EntityAdapter` test double. `list()` filters `rows` through
|
|
30
|
+
* `predicate` and paginates by array index: `cursor` is the (stringified)
|
|
31
|
+
* index of the last row already served (`null` means start from the
|
|
32
|
+
* beginning), and the returned `nextCursor` is that same kind of value for
|
|
33
|
+
* the next call — `null` once nothing is left. `getPoints()` runs the same
|
|
34
|
+
* predicate filter and projects each surviving row through `toLatLng`
|
|
35
|
+
* (further narrowed to `bounds` when one is given). `idOf` defaults to
|
|
36
|
+
* reading `row.id`, mirroring most real adapters, but can be overridden for
|
|
37
|
+
* rows keyed by something else.
|
|
38
|
+
*/
|
|
39
|
+
declare class InMemoryEntityAdapter<TEntity, TFilters> implements EntityAdapter<TEntity, TFilters> {
|
|
40
|
+
private readonly rows;
|
|
41
|
+
private readonly predicate;
|
|
42
|
+
private readonly toLatLng;
|
|
43
|
+
private readonly idOf;
|
|
44
|
+
constructor(rows: TEntity[], predicate: (row: TEntity, filters: TFilters) => boolean, toLatLng: (row: TEntity) => LatLng, idOf?: (row: TEntity) => EntityId);
|
|
45
|
+
list(filters: TFilters, page: PageRequest): Promise<Page<TEntity>>;
|
|
46
|
+
getPoints(filters: TFilters, bounds?: Bounds): Promise<MapPoint<TEntity>[]>;
|
|
47
|
+
getById(id: EntityId): Promise<TEntity>;
|
|
48
|
+
private isInBounds;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { FakeMapProvider, InMemoryEntityAdapter };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
var o=class{mounts=[];renderedLayers=[];removedLayers=[];fitBoundsCalls=[];destroyed=[];destroyCount=0;boundsListeners=new Set;mount(t,e){let n={raw:{el:t,opts:e}};return this.mounts.push(n),n}renderLayer(t,e){this.renderedLayers.push(e);let n=!1;return()=>{if(n)return;n=!0;let s=this.renderedLayers.indexOf(e);s!==-1&&(this.renderedLayers.splice(s,1),this.removedLayers.push(e))}}onBoundsChange(t,e){return this.boundsListeners.add(e),()=>{this.boundsListeners.delete(e)}}emitBounds(t){for(let e of this.boundsListeners)e(t)}fitBounds(t,e){this.fitBoundsCalls.push(e)}destroy(t){this.destroyed.push(t),this.destroyCount+=1,this.boundsListeners.clear()}};var d=class{constructor(t,e,n,s=r=>r.id){this.rows=t;this.predicate=e;this.toLatLng=n;this.idOf=s}rows;predicate;toLatLng;idOf;async list(t,e){let n=this.rows.filter(u=>this.predicate(u,t)),s=e.cursor==null?0:Number(e.cursor)+1,r=n.slice(s,s+e.limit),i=s+r.length-1,l=i+1<n.length;return{items:r,nextCursor:l?String(i):null,total:n.length}}async getPoints(t,e){let s=this.rows.filter(r=>this.predicate(r,t)).map(r=>({id:this.idOf(r),position:this.toLatLng(r),entity:r}));return e?s.filter(r=>this.isInBounds(r.position,e)):s}async getById(t){let e=this.rows.find(n=>this.idOf(n)===t);if(!e)throw new Error(`InMemoryEntityAdapter: no row with id "${String(t)}"`);return e}isInBounds(t,e){return t.lat<=e.north&&t.lat>=e.south&&t.lng>=e.west&&t.lng<=e.east}};export{o as FakeMapProvider,d as InMemoryEntityAdapter};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Q as QueryParams } from './entity-adapter.interface-BDgbSxhq.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DOM-agnostic abstraction over "the URL's query string" — or, in tests/SSR,
|
|
5
|
+
* an in-memory stand-in (`MemoryHistoryPort`). `UrlSyncController` talks to
|
|
6
|
+
* this interface only, never to `window.location`/`history` directly, so it
|
|
7
|
+
* stays framework- and environment-free like the rest of `src/core`.
|
|
8
|
+
*/
|
|
9
|
+
interface HistoryPort {
|
|
10
|
+
getQuery(): QueryParams;
|
|
11
|
+
setQuery(params: QueryParams): void;
|
|
12
|
+
subscribe(cb: () => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UrlSyncEngine<TFilters> {
|
|
16
|
+
subscribe(cb: () => void): () => void;
|
|
17
|
+
readonly state: {
|
|
18
|
+
readonly filters: TFilters;
|
|
19
|
+
};
|
|
20
|
+
applyFilters(patch: Partial<TFilters>): Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
interface UrlSyncOptions<TFilters> {
|
|
23
|
+
history: HistoryPort;
|
|
24
|
+
toQuery(filters: TFilters): QueryParams;
|
|
25
|
+
toFilters(query: QueryParams): Partial<TFilters>;
|
|
26
|
+
hydrateOnStart?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Bidirectional, DOM-agnostic sync between an engine's `filters` and a
|
|
30
|
+
* `HistoryPort`'s query params. Framework-free: talks to `UrlSyncEngine`
|
|
31
|
+
* (a structural subset `ListingEngine` satisfies) and `HistoryPort`, nothing
|
|
32
|
+
* else — no `window`, no React.
|
|
33
|
+
*
|
|
34
|
+
* OPTIONAL, not the primary path: `styled/listing-app.tsx`'s turnkey
|
|
35
|
+
* `ListingApp` (the package's main-entry default) does NOT wire this up —
|
|
36
|
+
* it uses an event-based URL API instead (`initialFilters` in,
|
|
37
|
+
* `onFiltersChange` out; see that file's doc comment), so the library never
|
|
38
|
+
* touches `window.history` by default. This class remains exported for
|
|
39
|
+
* consumers who explicitly want the library to own history writes/reads
|
|
40
|
+
* itself (e.g. via `/shadcn`'s `ListingApp.urlSync`, or wiring it directly
|
|
41
|
+
* against a hand-built engine).
|
|
42
|
+
*
|
|
43
|
+
* Echo-loop guard: both subscriptions below are driven by the SAME
|
|
44
|
+
* `isSyncing` flag. `ListingEngine.applyFilters()` and `MemoryHistoryPort`
|
|
45
|
+
* (and any real browser HistoryPort) both notify their subscribers
|
|
46
|
+
* SYNCHRONOUSLY as part of the write (`store.setFilters()` -> `notify()`
|
|
47
|
+
* happens before any debounce timer, and `MemoryHistoryPort.setQuery()`
|
|
48
|
+
* notifies before returning) — so when this controller initiates a write on
|
|
49
|
+
* one side, the reciprocal subscription on the other side fires within the
|
|
50
|
+
* very same call stack, before `isSyncing` is reset. Wrapping each
|
|
51
|
+
* controller-initiated write in `isSyncing = true; ...; isSyncing = false`
|
|
52
|
+
* (via try/finally, so a throwing `toQuery`/`toFilters`/notify can't leave it
|
|
53
|
+
* stuck) means that reciprocal callback observes `isSyncing === true` and
|
|
54
|
+
* short-circuits instead of writing back — one hop each direction, never a
|
|
55
|
+
* cascade. `hydrateOnStart`'s initial engine write is wrapped the same way,
|
|
56
|
+
* since it must not immediately echo the just-hydrated filters back out to
|
|
57
|
+
* history as a redundant `setQuery`.
|
|
58
|
+
*
|
|
59
|
+
* `isSyncing` only covers that SYNCHRONOUS window, though — the real
|
|
60
|
+
* `ListingEngine.applyFilters()` notifies AGAIN, asynchronously: after
|
|
61
|
+
* `await adapter.list(...)` resolves, `store.setResults()` and
|
|
62
|
+
* `store.setLoading(false)` each call `notify()`, well after `isSyncing` has
|
|
63
|
+
* already been reset to `false` by the `withSyncGuard` that kicked the query
|
|
64
|
+
* off. Left unguarded, that async tail would fire the engine subscription
|
|
65
|
+
* below and echo an identical (but spurious) `history.setQuery(...)` on
|
|
66
|
+
* every completed query. Rather than widening `isSyncing` to cover the whole
|
|
67
|
+
* async query (which would also swallow legitimate concurrent history
|
|
68
|
+
* changes that land mid-query), `syncEngineToHistory` is idempotent BY
|
|
69
|
+
* VALUE instead: it computes the target query and skips the write entirely
|
|
70
|
+
* when it already matches `history.getQuery()` — timing-independent, so it
|
|
71
|
+
* suppresses the async-tail echo without touching `isSyncing` at all.
|
|
72
|
+
*/
|
|
73
|
+
declare class UrlSyncController<TFilters> {
|
|
74
|
+
private readonly history;
|
|
75
|
+
private readonly toQueryFn;
|
|
76
|
+
private readonly toFiltersFn;
|
|
77
|
+
private readonly hydrateOnStart;
|
|
78
|
+
private isSyncing;
|
|
79
|
+
private unsubscribeEngine;
|
|
80
|
+
private unsubscribeHistory;
|
|
81
|
+
constructor(opts: UrlSyncOptions<TFilters>);
|
|
82
|
+
start(engine: UrlSyncEngine<TFilters>): void;
|
|
83
|
+
stop(): void;
|
|
84
|
+
private syncEngineToHistory;
|
|
85
|
+
private applyFiltersSafely;
|
|
86
|
+
private withSyncGuard;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { type HistoryPort as H, UrlSyncController as U, type UrlSyncEngine as a, type UrlSyncOptions as b };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Q as QueryParams } from './entity-adapter.interface-BDgbSxhq.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DOM-agnostic abstraction over "the URL's query string" — or, in tests/SSR,
|
|
5
|
+
* an in-memory stand-in (`MemoryHistoryPort`). `UrlSyncController` talks to
|
|
6
|
+
* this interface only, never to `window.location`/`history` directly, so it
|
|
7
|
+
* stays framework- and environment-free like the rest of `src/core`.
|
|
8
|
+
*/
|
|
9
|
+
interface HistoryPort {
|
|
10
|
+
getQuery(): QueryParams;
|
|
11
|
+
setQuery(params: QueryParams): void;
|
|
12
|
+
subscribe(cb: () => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UrlSyncEngine<TFilters> {
|
|
16
|
+
subscribe(cb: () => void): () => void;
|
|
17
|
+
readonly state: {
|
|
18
|
+
readonly filters: TFilters;
|
|
19
|
+
};
|
|
20
|
+
applyFilters(patch: Partial<TFilters>): Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
interface UrlSyncOptions<TFilters> {
|
|
23
|
+
history: HistoryPort;
|
|
24
|
+
toQuery(filters: TFilters): QueryParams;
|
|
25
|
+
toFilters(query: QueryParams): Partial<TFilters>;
|
|
26
|
+
hydrateOnStart?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Bidirectional, DOM-agnostic sync between an engine's `filters` and a
|
|
30
|
+
* `HistoryPort`'s query params. Framework-free: talks to `UrlSyncEngine`
|
|
31
|
+
* (a structural subset `ListingEngine` satisfies) and `HistoryPort`, nothing
|
|
32
|
+
* else — no `window`, no React.
|
|
33
|
+
*
|
|
34
|
+
* OPTIONAL, not the primary path: `styled/listing-app.tsx`'s turnkey
|
|
35
|
+
* `ListingApp` (the package's main-entry default) does NOT wire this up —
|
|
36
|
+
* it uses an event-based URL API instead (`initialFilters` in,
|
|
37
|
+
* `onFiltersChange` out; see that file's doc comment), so the library never
|
|
38
|
+
* touches `window.history` by default. This class remains exported for
|
|
39
|
+
* consumers who explicitly want the library to own history writes/reads
|
|
40
|
+
* itself (e.g. via `/shadcn`'s `ListingApp.urlSync`, or wiring it directly
|
|
41
|
+
* against a hand-built engine).
|
|
42
|
+
*
|
|
43
|
+
* Echo-loop guard: both subscriptions below are driven by the SAME
|
|
44
|
+
* `isSyncing` flag. `ListingEngine.applyFilters()` and `MemoryHistoryPort`
|
|
45
|
+
* (and any real browser HistoryPort) both notify their subscribers
|
|
46
|
+
* SYNCHRONOUSLY as part of the write (`store.setFilters()` -> `notify()`
|
|
47
|
+
* happens before any debounce timer, and `MemoryHistoryPort.setQuery()`
|
|
48
|
+
* notifies before returning) — so when this controller initiates a write on
|
|
49
|
+
* one side, the reciprocal subscription on the other side fires within the
|
|
50
|
+
* very same call stack, before `isSyncing` is reset. Wrapping each
|
|
51
|
+
* controller-initiated write in `isSyncing = true; ...; isSyncing = false`
|
|
52
|
+
* (via try/finally, so a throwing `toQuery`/`toFilters`/notify can't leave it
|
|
53
|
+
* stuck) means that reciprocal callback observes `isSyncing === true` and
|
|
54
|
+
* short-circuits instead of writing back — one hop each direction, never a
|
|
55
|
+
* cascade. `hydrateOnStart`'s initial engine write is wrapped the same way,
|
|
56
|
+
* since it must not immediately echo the just-hydrated filters back out to
|
|
57
|
+
* history as a redundant `setQuery`.
|
|
58
|
+
*
|
|
59
|
+
* `isSyncing` only covers that SYNCHRONOUS window, though — the real
|
|
60
|
+
* `ListingEngine.applyFilters()` notifies AGAIN, asynchronously: after
|
|
61
|
+
* `await adapter.list(...)` resolves, `store.setResults()` and
|
|
62
|
+
* `store.setLoading(false)` each call `notify()`, well after `isSyncing` has
|
|
63
|
+
* already been reset to `false` by the `withSyncGuard` that kicked the query
|
|
64
|
+
* off. Left unguarded, that async tail would fire the engine subscription
|
|
65
|
+
* below and echo an identical (but spurious) `history.setQuery(...)` on
|
|
66
|
+
* every completed query. Rather than widening `isSyncing` to cover the whole
|
|
67
|
+
* async query (which would also swallow legitimate concurrent history
|
|
68
|
+
* changes that land mid-query), `syncEngineToHistory` is idempotent BY
|
|
69
|
+
* VALUE instead: it computes the target query and skips the write entirely
|
|
70
|
+
* when it already matches `history.getQuery()` — timing-independent, so it
|
|
71
|
+
* suppresses the async-tail echo without touching `isSyncing` at all.
|
|
72
|
+
*/
|
|
73
|
+
declare class UrlSyncController<TFilters> {
|
|
74
|
+
private readonly history;
|
|
75
|
+
private readonly toQueryFn;
|
|
76
|
+
private readonly toFiltersFn;
|
|
77
|
+
private readonly hydrateOnStart;
|
|
78
|
+
private isSyncing;
|
|
79
|
+
private unsubscribeEngine;
|
|
80
|
+
private unsubscribeHistory;
|
|
81
|
+
constructor(opts: UrlSyncOptions<TFilters>);
|
|
82
|
+
start(engine: UrlSyncEngine<TFilters>): void;
|
|
83
|
+
stop(): void;
|
|
84
|
+
private syncEngineToHistory;
|
|
85
|
+
private applyFiltersSafely;
|
|
86
|
+
private withSyncGuard;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { type HistoryPort as H, UrlSyncController as U, type UrlSyncEngine as a, type UrlSyncOptions as b };
|
package/package.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-listing-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Headless, composable listing engine for React: filterable list + Google-Maps multi-layer map, with pluggable data adapters, a filter/dataset registry, injectable components, and a shadcn-compatible styled adapter.",
|
|
6
|
+
"funding": [
|
|
7
|
+
{
|
|
8
|
+
"type": "github",
|
|
9
|
+
"url": "https://github.com/sponsors/knazark"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "individual",
|
|
13
|
+
"url": "https://send.monobank.ua/jar/2jfphHthfY"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"listing",
|
|
19
|
+
"map",
|
|
20
|
+
"google-maps",
|
|
21
|
+
"filters",
|
|
22
|
+
"real-estate",
|
|
23
|
+
"directory",
|
|
24
|
+
"store-locator",
|
|
25
|
+
"headless",
|
|
26
|
+
"typescript",
|
|
27
|
+
"nextjs",
|
|
28
|
+
"react18",
|
|
29
|
+
"react19",
|
|
30
|
+
"shadcn"
|
|
31
|
+
],
|
|
32
|
+
"author": "knazark",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/knazark/react-listing-engine.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/knazark/react-listing-engine#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/knazark/react-listing-engine/issues"
|
|
40
|
+
},
|
|
41
|
+
"type": "module",
|
|
42
|
+
"sideEffects": [
|
|
43
|
+
"./dist/styles.css"
|
|
44
|
+
],
|
|
45
|
+
"main": "./dist/index.cjs",
|
|
46
|
+
"module": "./dist/index.js",
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"exports": {
|
|
49
|
+
".": {
|
|
50
|
+
"types": "./dist/index.d.ts",
|
|
51
|
+
"import": "./dist/index.js",
|
|
52
|
+
"require": "./dist/index.cjs"
|
|
53
|
+
},
|
|
54
|
+
"./shadcn": {
|
|
55
|
+
"types": "./dist/shadcn/index.d.ts",
|
|
56
|
+
"import": "./dist/shadcn/index.js",
|
|
57
|
+
"require": "./dist/shadcn/index.cjs"
|
|
58
|
+
},
|
|
59
|
+
"./styled": {
|
|
60
|
+
"types": "./dist/styled/index.d.ts",
|
|
61
|
+
"import": "./dist/styled/index.js",
|
|
62
|
+
"require": "./dist/styled/index.cjs"
|
|
63
|
+
},
|
|
64
|
+
"./styles.css": "./dist/styles.css",
|
|
65
|
+
"./maps/google": {
|
|
66
|
+
"types": "./dist/maps/google/index.d.ts",
|
|
67
|
+
"import": "./dist/maps/google/index.js",
|
|
68
|
+
"require": "./dist/maps/google/index.cjs"
|
|
69
|
+
},
|
|
70
|
+
"./presets/rental": {
|
|
71
|
+
"types": "./dist/presets/rental/index.d.ts",
|
|
72
|
+
"import": "./dist/presets/rental/index.js",
|
|
73
|
+
"require": "./dist/presets/rental/index.cjs"
|
|
74
|
+
},
|
|
75
|
+
"./testing": {
|
|
76
|
+
"types": "./dist/testing/index.d.ts",
|
|
77
|
+
"import": "./dist/testing/index.js",
|
|
78
|
+
"require": "./dist/testing/index.cjs"
|
|
79
|
+
},
|
|
80
|
+
"./package.json": "./package.json"
|
|
81
|
+
},
|
|
82
|
+
"files": [
|
|
83
|
+
"dist",
|
|
84
|
+
"README.md",
|
|
85
|
+
"LICENSE",
|
|
86
|
+
"CHANGELOG.md"
|
|
87
|
+
],
|
|
88
|
+
"peerDependencies": {
|
|
89
|
+
"@googlemaps/js-api-loader": ">=1",
|
|
90
|
+
"@googlemaps/markerclusterer": ">=2",
|
|
91
|
+
"@radix-ui/react-slot": ">=1",
|
|
92
|
+
"react": ">=18",
|
|
93
|
+
"react-dom": ">=18"
|
|
94
|
+
},
|
|
95
|
+
"peerDependenciesMeta": {
|
|
96
|
+
"@googlemaps/js-api-loader": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"@googlemaps/markerclusterer": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"@radix-ui/react-slot": {
|
|
103
|
+
"optional": true
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"devDependencies": {
|
|
107
|
+
"@changesets/cli": "^2.31.1",
|
|
108
|
+
"@googlemaps/js-api-loader": "^2.1.1",
|
|
109
|
+
"@googlemaps/markerclusterer": "^2.6.2",
|
|
110
|
+
"@testing-library/jest-dom": "^7.0.0",
|
|
111
|
+
"@testing-library/react": "^16.3.2",
|
|
112
|
+
"@types/google.maps": "^3.65.3",
|
|
113
|
+
"@types/node": "^26.1.1",
|
|
114
|
+
"@types/react": "^19.2.17",
|
|
115
|
+
"@types/react-dom": "^19.2.3",
|
|
116
|
+
"eslint": "^10.8.0",
|
|
117
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
118
|
+
"happy-dom": "^20.11.1",
|
|
119
|
+
"lucide-react": "^1.26.0",
|
|
120
|
+
"tsup": "^8.5.1",
|
|
121
|
+
"typescript": "^5.9.3",
|
|
122
|
+
"typescript-eslint": "^8.65.0",
|
|
123
|
+
"vitest": "^4.1.10"
|
|
124
|
+
},
|
|
125
|
+
"dependencies": {
|
|
126
|
+
"clsx": "^2.1.1",
|
|
127
|
+
"tailwind-merge": "^3.6.0"
|
|
128
|
+
},
|
|
129
|
+
"engines": {
|
|
130
|
+
"node": ">=18"
|
|
131
|
+
},
|
|
132
|
+
"publishConfig": {
|
|
133
|
+
"access": "public"
|
|
134
|
+
},
|
|
135
|
+
"scripts": {
|
|
136
|
+
"build": "tsup",
|
|
137
|
+
"test": "vitest run",
|
|
138
|
+
"test:watch": "vitest",
|
|
139
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
140
|
+
"lint": "eslint .",
|
|
141
|
+
"release": "changeset publish"
|
|
142
|
+
}
|
|
143
|
+
}
|