@zappie/foundations 0.1.2 → 0.1.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/CHANGELOG.md +19 -0
- package/README.md +123 -0
- package/dist/branch-map/geometry.js +43 -0
- package/dist/branch-map/geometry.js.map +7 -0
- package/dist/branch-map/index.js +127 -0
- package/dist/branch-map/index.js.map +7 -0
- package/dist/branch-map/loader.js +39 -0
- package/dist/branch-map/loader.js.map +7 -0
- package/dist/branch-map/runtime.js +159 -0
- package/dist/branch-map/runtime.js.map +7 -0
- package/dist/deferred/index.js +111 -0
- package/dist/deferred/index.js.map +7 -0
- package/dist/index.d.ts +151 -6
- package/dist/index.js +5 -0
- package/dist/index.js.map +2 -2
- package/dist/item-form/index.js.map +1 -1
- package/dist/item-form/root.js +25 -8
- package/dist/item-form/root.js.map +2 -2
- package/dist/overlay/react.js +21 -11
- package/dist/overlay/react.js.map +3 -3
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @zappie/foundations
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4f3cdc3: Add `deferred()` and `useIntentPreload()`, which load an overlay's code on demand and render it without suspending, so a first open no longer waits for React's 300 ms reveal. `BranchMap` now fits the shopper's location with the branches, pans to a newly selected branch instead of fitting again, reports load failures through `onLoadError` and `renderLoadError` with a retry, and can label pins with the branch name. `ItemForm.Root` focuses the first invalid group without scrolling and reports the invalid groups through `onInvalid`.
|
|
8
|
+
- e1f4cf0: `Overlay` prefetches its engine once the page has loaded and the browser is idle, without waiting for an `Overlay.Root` to mount. An overlay loaded with `deferred()` mounts its Root only on the tap, so its first open used to wait for the engine's download as well.
|
|
9
|
+
- 3d56337: Add a fitted-zoom limit, image pins, and an optional map and satellite switch to `BranchMap`.
|
|
10
|
+
- Updated dependencies [4f3cdc3]
|
|
11
|
+
- @zappie/sdk@0.1.8
|
|
12
|
+
|
|
13
|
+
## 0.1.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 4bfb1b2: Add BranchMap with lazy Maps loading, themeable pins, controlled branch selection, shopper location and a fallback for branches without coordinates.
|
|
18
|
+
- Updated dependencies [4bfb1b2]
|
|
19
|
+
- Updated dependencies [666ba1c]
|
|
20
|
+
- @zappie/sdk@0.1.7
|
|
21
|
+
|
|
3
22
|
## 0.1.2
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -49,3 +49,126 @@ Foundations is at `0.x`. Its API can change in a minor release while it settles.
|
|
|
49
49
|
## License
|
|
50
50
|
|
|
51
51
|
MIT
|
|
52
|
+
|
|
53
|
+
## Deferred overlays
|
|
54
|
+
|
|
55
|
+
Keep a bag, branch finder or search dialog off the first load without `React.lazy`.
|
|
56
|
+
React holds a suspended boundary's first reveal for about 300 ms, so a lazy overlay
|
|
57
|
+
opens late. `deferred()` loads the code on demand and renders on the frame it arrives.
|
|
58
|
+
It never suspends.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { deferred, useIntentPreload } from "@zappie/foundations";
|
|
62
|
+
|
|
63
|
+
const BagSheet = deferred(() => import("./bag-sheet"));
|
|
64
|
+
// Or a named export: deferred(() => import("./finder").then((m) => m.Finder))
|
|
65
|
+
|
|
66
|
+
function Header() {
|
|
67
|
+
const [open, setOpen] = useState(false);
|
|
68
|
+
const intent = useIntentPreload(BagSheet);
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<button {...intent} onClick={() => setOpen(true)}>
|
|
72
|
+
Bag
|
|
73
|
+
</button>
|
|
74
|
+
<BagSheet.Component open={open} onClose={() => setOpen(false)} />
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- `deferred(load)` takes a function that imports a module whose default export
|
|
81
|
+
is the component, or that resolves to the component. Create it once at module
|
|
82
|
+
scope.
|
|
83
|
+
- `Component` renders nothing until `open` is first true and the code is in.
|
|
84
|
+
When `open` turns true it loads the code and renders as soon as it arrives.
|
|
85
|
+
After that first open the component stays mounted and receives every prop,
|
|
86
|
+
`open` included, so it can animate out and return focus when it closes.
|
|
87
|
+
- Pass `fallback` to hold an inline part's place while its code loads, for
|
|
88
|
+
example a placeholder label. It shows only while `open` is true and the code
|
|
89
|
+
is not in yet, and it never suspends.
|
|
90
|
+
- `preload()` starts the load and returns its promise. Calls share one load. A
|
|
91
|
+
failed load is forgotten, so the next `preload()` or the next open tries again.
|
|
92
|
+
- `preloadWhenIdle({ timeout? })` loads the code when the browser is idle and
|
|
93
|
+
returns a function that cancels it. Use it only for small chunks. It does
|
|
94
|
+
nothing on the server.
|
|
95
|
+
- `useIntentPreload(target)` returns `{ onPointerEnter, onPointerDown, onFocus }`
|
|
96
|
+
for the trigger, so the code is usually in by the click.
|
|
97
|
+
|
|
98
|
+
Importing the helper never touches `window`. On the server `Component` renders
|
|
99
|
+
nothing, and it works under `StrictMode`.
|
|
100
|
+
|
|
101
|
+
## Item form focus
|
|
102
|
+
|
|
103
|
+
When an add fails validation, `ItemForm.Root` focuses the first invalid group
|
|
104
|
+
with `preventScroll`, so the page doesn't jump. Pass `onInvalid(groups)` to
|
|
105
|
+
scroll it into view yourself. `groups` lists the invalid groups in display
|
|
106
|
+
order, each with its `groupId`, its `group` (undefined for a group the
|
|
107
|
+
template renders from its own model), its `element` and its `issues`.
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<ItemForm.Root
|
|
111
|
+
item={item}
|
|
112
|
+
onInvalid={(groups) =>
|
|
113
|
+
groups[0]?.element?.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Branch map
|
|
119
|
+
|
|
120
|
+
`BranchMap` accepts SDK `Branch[]` data, `selectedBranchId`, `onSelect(branch)`,
|
|
121
|
+
`browserKey`, and optional `shopperLocation: { lat, lng }`. Use `maxZoom` to
|
|
122
|
+
limit the zoom after fitting, `pinImageUrl` to show an image in each branch pin,
|
|
123
|
+
`mapTypeControl` to show the map and satellite switch, and `pinLabel` to show
|
|
124
|
+
the branch name above the selected pin (`"selected"`) or every pin (`"all"`).
|
|
125
|
+
It also accepts standard div attributes such as `className` and `style`.
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { BranchMap } from "@zappie/foundations";
|
|
129
|
+
|
|
130
|
+
<BranchMap
|
|
131
|
+
branches={branches}
|
|
132
|
+
selectedBranchId={selectedBranchId}
|
|
133
|
+
onSelect={(branch) => setSelectedBranchId(branch.id)}
|
|
134
|
+
browserKey={integrations.googleMaps?.browserKey}
|
|
135
|
+
shopperLocation={shopperLocation}
|
|
136
|
+
pinImageUrl={store.brand.logoUrl}
|
|
137
|
+
pinLabel="selected"
|
|
138
|
+
mapTypeControl
|
|
139
|
+
onLoadError={(error) => report(error)}
|
|
140
|
+
renderLoadError={({ retry }) => (
|
|
141
|
+
<div role="status">
|
|
142
|
+
<p>Map didn't load</p>
|
|
143
|
+
<button type="button" onClick={retry}>
|
|
144
|
+
Try again
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
className="branch-map"
|
|
149
|
+
/>;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Set a height on `.branch-map`. Theme pins with `--branch-map-pin`,
|
|
153
|
+
`--branch-map-pin-selected`, `--branch-map-pin-border`, and
|
|
154
|
+
`--branch-map-pin-size`. Pins expose `data-selected` and `aria-pressed`. Theme
|
|
155
|
+
pin labels with `--branch-map-label-background`, `--branch-map-label-color` and
|
|
156
|
+
`--branch-map-label-border`. Labels are marked `data-branch-map-pin-label` and
|
|
157
|
+
hidden from assistive technology, because the pin already carries the name.
|
|
158
|
+
|
|
159
|
+
The map fits branches with valid coordinates, including across the date line.
|
|
160
|
+
With a `shopperLocation`, it fits the shopper with the branches, with 88 px of
|
|
161
|
+
padding. After that first fit, selecting another branch pans to it and keeps the
|
|
162
|
+
zoom. The map fits again only when the branches, the shopper location or
|
|
163
|
+
`maxZoom` change. Branches without coordinates remain selectable with “Map
|
|
164
|
+
location unavailable”. `maxZoom` defaults to `15` with a shopper location and
|
|
165
|
+
`14` without one. `mapTypeControl` defaults to `false`. Without a `pinImageUrl`,
|
|
166
|
+
branch pins keep using the existing CSS variables.
|
|
167
|
+
|
|
168
|
+
The component renders nothing without a key. It loads its map runtime and the
|
|
169
|
+
Maps JavaScript API after mount, sharing one script across mounted maps.
|
|
170
|
+
Use one browser key per page. A loading failure calls `onLoadError(error)`.
|
|
171
|
+
With `renderLoadError({ error, retry })` you render the message, and `retry`
|
|
172
|
+
loads the map again. Without it the map shows “Map location unavailable”.
|
|
173
|
+
The optional location button pans to coordinates supplied by your app. Your app
|
|
174
|
+
requests geolocation permission and supplies those coordinates.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/branch-map/geometry.ts
|
|
2
|
+
function validCoordinates(value) {
|
|
3
|
+
return !!value && Number.isFinite(value.lat) && Number.isFinite(value.lng) && Math.abs(value.lat) <= 90 && Math.abs(value.lng) <= 180;
|
|
4
|
+
}
|
|
5
|
+
function visibleBranches(branches) {
|
|
6
|
+
return branches.filter(
|
|
7
|
+
(branch) => validCoordinates(branch.coordinates)
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
function branchBounds(branches, location) {
|
|
11
|
+
const points = visibleBranches(branches).map((branch) => branch.coordinates);
|
|
12
|
+
if (validCoordinates(location)) {
|
|
13
|
+
points.push(location);
|
|
14
|
+
}
|
|
15
|
+
if (!points.length) {
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
const longitudes = points.map((point) => (point.lng + 360) % 360).sort((a, b) => a - b);
|
|
19
|
+
let gap = -1;
|
|
20
|
+
let start = 0;
|
|
21
|
+
for (let index = 0; index < longitudes.length; index++) {
|
|
22
|
+
const next = longitudes[(index + 1) % longitudes.length] + (index === longitudes.length - 1 ? 360 : 0);
|
|
23
|
+
if (next - longitudes[index] > gap) {
|
|
24
|
+
gap = next - longitudes[index];
|
|
25
|
+
start = (index + 1) % longitudes.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const normalize = (lng) => lng > 180 ? lng - 360 : lng;
|
|
29
|
+
return {
|
|
30
|
+
north: Math.max(...points.map((point) => point.lat)),
|
|
31
|
+
south: Math.min(...points.map((point) => point.lat)),
|
|
32
|
+
west: normalize(longitudes[start]),
|
|
33
|
+
east: normalize(
|
|
34
|
+
longitudes[(start + longitudes.length - 1) % longitudes.length]
|
|
35
|
+
)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
branchBounds,
|
|
40
|
+
validCoordinates,
|
|
41
|
+
visibleBranches
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=geometry.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/branch-map/geometry.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Branch } from \"@zappie/sdk\";\n\nexport type Coordinates = { lat: number; lng: number };\nexport function validCoordinates(value?: Coordinates): value is Coordinates {\n return (\n !!value &&\n Number.isFinite(value.lat) &&\n Number.isFinite(value.lng) &&\n Math.abs(value.lat) <= 90 &&\n Math.abs(value.lng) <= 180\n );\n}\nexport function visibleBranches(branches: Branch[]) {\n return branches.filter(\n (branch): branch is Branch & { coordinates: Coordinates } =>\n validCoordinates(branch.coordinates),\n );\n}\nexport function branchBounds(branches: Branch[], location?: Coordinates) {\n const points = visibleBranches(branches).map((branch) => branch.coordinates);\n if (validCoordinates(location)) {\n points.push(location);\n }\n if (!points.length) {\n return undefined;\n }\n // The largest longitude gap lies outside the bounds, including across the date line.\n const longitudes = points\n .map((point) => (point.lng + 360) % 360)\n .sort((a, b) => a - b);\n let gap = -1;\n let start = 0;\n for (let index = 0; index < longitudes.length; index++) {\n const next =\n longitudes[(index + 1) % longitudes.length]! +\n (index === longitudes.length - 1 ? 360 : 0);\n if (next - longitudes[index]! > gap) {\n gap = next - longitudes[index]!;\n start = (index + 1) % longitudes.length;\n }\n }\n const normalize = (lng: number) => (lng > 180 ? lng - 360 : lng);\n return {\n north: Math.max(...points.map((point) => point.lat)),\n south: Math.min(...points.map((point) => point.lat)),\n west: normalize(longitudes[start]!),\n east: normalize(\n longitudes[(start + longitudes.length - 1) % longitudes.length]!,\n ),\n };\n}\n"],
|
|
5
|
+
"mappings": ";AAGO,SAAS,iBAAiB,OAA2C;AAC1E,SACE,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,GAAG,KACzB,OAAO,SAAS,MAAM,GAAG,KACzB,KAAK,IAAI,MAAM,GAAG,KAAK,MACvB,KAAK,IAAI,MAAM,GAAG,KAAK;AAE3B;AACO,SAAS,gBAAgB,UAAoB;AAClD,SAAO,SAAS;AAAA,IACd,CAAC,WACC,iBAAiB,OAAO,WAAW;AAAA,EACvC;AACF;AACO,SAAS,aAAa,UAAoB,UAAwB;AACvE,QAAM,SAAS,gBAAgB,QAAQ,EAAE,IAAI,CAAC,WAAW,OAAO,WAAW;AAC3E,MAAI,iBAAiB,QAAQ,GAAG;AAC9B,WAAO,KAAK,QAAQ;AAAA,EACtB;AACA,MAAI,CAAC,OAAO,QAAQ;AAClB,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,OAChB,IAAI,CAAC,WAAW,MAAM,MAAM,OAAO,GAAG,EACtC,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AACvB,MAAI,MAAM;AACV,MAAI,QAAQ;AACZ,WAAS,QAAQ,GAAG,QAAQ,WAAW,QAAQ,SAAS;AACtD,UAAM,OACJ,YAAY,QAAQ,KAAK,WAAW,MAAM,KACzC,UAAU,WAAW,SAAS,IAAI,MAAM;AAC3C,QAAI,OAAO,WAAW,KAAK,IAAK,KAAK;AACnC,YAAM,OAAO,WAAW,KAAK;AAC7B,eAAS,QAAQ,KAAK,WAAW;AAAA,IACnC;AAAA,EACF;AACA,QAAM,YAAY,CAAC,QAAiB,MAAM,MAAM,MAAM,MAAM;AAC5D,SAAO;AAAA,IACL,OAAO,KAAK,IAAI,GAAG,OAAO,IAAI,CAAC,UAAU,MAAM,GAAG,CAAC;AAAA,IACnD,OAAO,KAAK,IAAI,GAAG,OAAO,IAAI,CAAC,UAAU,MAAM,GAAG,CAAC;AAAA,IACnD,MAAM,UAAU,WAAW,KAAK,CAAE;AAAA,IAClC,MAAM;AAAA,MACJ,YAAY,QAAQ,WAAW,SAAS,KAAK,WAAW,MAAM;AAAA,IAChE;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/branch-map/index.tsx
|
|
4
|
+
import {
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
validCoordinates,
|
|
11
|
+
visibleBranches
|
|
12
|
+
} from "./geometry.js";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
function BranchMap({
|
|
15
|
+
branches,
|
|
16
|
+
selectedBranchId,
|
|
17
|
+
onSelect,
|
|
18
|
+
shopperLocation,
|
|
19
|
+
browserKey,
|
|
20
|
+
maxZoom,
|
|
21
|
+
pinImageUrl,
|
|
22
|
+
mapTypeControl = false,
|
|
23
|
+
pinLabel,
|
|
24
|
+
onLoadError,
|
|
25
|
+
renderLoadError,
|
|
26
|
+
...props
|
|
27
|
+
}) {
|
|
28
|
+
const element = useRef(null);
|
|
29
|
+
const [runtime, setRuntime] = useState();
|
|
30
|
+
const [failure, setFailure] = useState();
|
|
31
|
+
const [attempt, setAttempt] = useState(0);
|
|
32
|
+
const reportError = useRef(onLoadError);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
reportError.current = onLoadError;
|
|
35
|
+
}, [onLoadError]);
|
|
36
|
+
const failed = failure !== void 0;
|
|
37
|
+
const effectiveMaxZoom = maxZoom ?? (validCoordinates(shopperLocation) ? 15 : 14);
|
|
38
|
+
const hasCoordinates = visibleBranches(branches).length > 0;
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!browserKey || !element.current || !hasCoordinates) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let disposed = false;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
let instance;
|
|
46
|
+
const target = element.current;
|
|
47
|
+
setFailure(void 0);
|
|
48
|
+
void import("./runtime.js").then(
|
|
49
|
+
({ createMap }) => createMap(target, browserKey, controller.signal, { mapTypeControl })
|
|
50
|
+
).then((map) => {
|
|
51
|
+
instance = map;
|
|
52
|
+
if (disposed) {
|
|
53
|
+
map.destroy();
|
|
54
|
+
} else {
|
|
55
|
+
setRuntime(map);
|
|
56
|
+
}
|
|
57
|
+
}).catch((error) => {
|
|
58
|
+
if (!disposed) {
|
|
59
|
+
setFailure({ error });
|
|
60
|
+
reportError.current?.(error);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
disposed = true;
|
|
65
|
+
controller.abort();
|
|
66
|
+
instance?.destroy();
|
|
67
|
+
setRuntime(void 0);
|
|
68
|
+
};
|
|
69
|
+
}, [browserKey, hasCoordinates, attempt]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
runtime?.update(branches, selectedBranchId, onSelect, shopperLocation, {
|
|
72
|
+
maxZoom: effectiveMaxZoom,
|
|
73
|
+
pinImageUrl,
|
|
74
|
+
mapTypeControl,
|
|
75
|
+
pinLabel
|
|
76
|
+
});
|
|
77
|
+
}, [
|
|
78
|
+
runtime,
|
|
79
|
+
branches,
|
|
80
|
+
selectedBranchId,
|
|
81
|
+
onSelect,
|
|
82
|
+
shopperLocation,
|
|
83
|
+
effectiveMaxZoom,
|
|
84
|
+
pinImageUrl,
|
|
85
|
+
mapTypeControl,
|
|
86
|
+
pinLabel
|
|
87
|
+
]);
|
|
88
|
+
if (!browserKey) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const unavailable = branches.filter(
|
|
92
|
+
(branch) => !validCoordinates(branch.coordinates)
|
|
93
|
+
);
|
|
94
|
+
return /* @__PURE__ */ jsxs("div", { ...props, "data-branch-map": "", children: [
|
|
95
|
+
/* @__PURE__ */ jsx(
|
|
96
|
+
"div",
|
|
97
|
+
{
|
|
98
|
+
hidden: failed || !hasCoordinates,
|
|
99
|
+
ref: element,
|
|
100
|
+
"data-branch-map-canvas": "",
|
|
101
|
+
style: { height: "100%" }
|
|
102
|
+
}
|
|
103
|
+
),
|
|
104
|
+
failure && renderLoadError ? renderLoadError({
|
|
105
|
+
error: failure.error,
|
|
106
|
+
retry: () => setAttempt((count) => count + 1)
|
|
107
|
+
}) : failed || !hasCoordinates ? /* @__PURE__ */ jsx("p", { role: "status", children: "Map location unavailable" }) : null,
|
|
108
|
+
unavailable.map((branch) => /* @__PURE__ */ jsxs(
|
|
109
|
+
"button",
|
|
110
|
+
{
|
|
111
|
+
type: "button",
|
|
112
|
+
"aria-pressed": branch.id === selectedBranchId,
|
|
113
|
+
onClick: () => onSelect(branch),
|
|
114
|
+
children: [
|
|
115
|
+
branch.name,
|
|
116
|
+
": Map location unavailable"
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
branch.id
|
|
120
|
+
)),
|
|
121
|
+
runtime && validCoordinates(shopperLocation) ? /* @__PURE__ */ jsx("button", { type: "button", onClick: () => runtime.locate(shopperLocation), children: "Use my current location" }) : null
|
|
122
|
+
] });
|
|
123
|
+
}
|
|
124
|
+
export {
|
|
125
|
+
BranchMap
|
|
126
|
+
};
|
|
127
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/branch-map/index.tsx"],
|
|
4
|
+
"sourcesContent": ["\"use client\";\nimport type { Branch } from \"@zappie/sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ReactNode,\n} from \"react\";\nimport {\n validCoordinates,\n visibleBranches,\n type Coordinates,\n} from \"./geometry\";\nimport type { createMap } from \"./runtime\";\n\n/** What a template gets to render when the map fails to load. */\nexport type BranchMapLoadErrorState = {\n /** Why the map failed to load. */\n error: unknown;\n /** Load the map again. */\n retry: () => void;\n};\n\n/** Branches, selection and optional shopper coordinates for BranchMap. */\nexport type BranchMapProps = Omit<\n HTMLAttributes<HTMLDivElement>,\n \"onSelect\"\n> & {\n branches: Branch[];\n selectedBranchId?: string;\n onSelect: (branch: Branch) => void;\n /** The shopper's coordinates. The map fits them with the branches. */\n shopperLocation?: Coordinates;\n browserKey?: string;\n /**\n * Highest zoom applied after fitting. Defaults to 15 when a shopper location\n * is shown and 14 otherwise.\n */\n maxZoom?: number;\n /** Image shown inside each branch pin. */\n pinImageUrl?: string;\n /** Show the map and satellite switch. Defaults to false. */\n mapTypeControl?: boolean;\n /** Show the branch name above the selected pin, or above every pin. Off by default. */\n pinLabel?: \"selected\" | \"all\";\n /** Called each time the map fails to load. */\n onLoadError?: (error: unknown) => void;\n /**\n * Render your own message when the map fails to load, such as a retry\n * button that calls `retry`. Without it the map shows \"Map location unavailable\".\n */\n renderLoadError?: (state: BranchMapLoadErrorState) => ReactNode;\n};\n\n/**\n * Show branch locations. Maps loads after mount when you supply a browser key.\n * Set the container height with CSS. Style pins with --branch-map-pin,\n * --branch-map-pin-selected, --branch-map-pin-border and --branch-map-pin-size.\n */\nexport function BranchMap({\n branches,\n selectedBranchId,\n onSelect,\n shopperLocation,\n browserKey,\n maxZoom,\n pinImageUrl,\n mapTypeControl = false,\n pinLabel,\n onLoadError,\n renderLoadError,\n ...props\n}: BranchMapProps) {\n const element = useRef<HTMLDivElement>(null);\n const [runtime, setRuntime] =\n useState<Awaited<ReturnType<typeof createMap>>>();\n const [failure, setFailure] = useState<{ error: unknown }>();\n const [attempt, setAttempt] = useState(0);\n const reportError = useRef(onLoadError);\n useEffect(() => {\n reportError.current = onLoadError;\n }, [onLoadError]);\n const failed = failure !== undefined;\n const effectiveMaxZoom =\n maxZoom ?? (validCoordinates(shopperLocation) ? 15 : 14);\n const hasCoordinates = visibleBranches(branches).length > 0;\n useEffect(() => {\n if (!browserKey || !element.current || !hasCoordinates) {\n return;\n }\n let disposed = false;\n const controller = new AbortController();\n let instance: Awaited<ReturnType<typeof createMap>> | undefined;\n const target = element.current;\n setFailure(undefined);\n void import(\"./runtime\")\n .then(({ createMap }) =>\n createMap(target, browserKey, controller.signal, { mapTypeControl }),\n )\n .then((map) => {\n instance = map;\n if (disposed) {\n map.destroy();\n } else {\n setRuntime(map);\n }\n })\n .catch((error: unknown) => {\n if (!disposed) {\n setFailure({ error });\n reportError.current?.(error);\n }\n });\n return () => {\n disposed = true;\n controller.abort();\n instance?.destroy();\n setRuntime(undefined);\n };\n }, [browserKey, hasCoordinates, attempt]);\n useEffect(() => {\n runtime?.update(branches, selectedBranchId, onSelect, shopperLocation, {\n maxZoom: effectiveMaxZoom,\n pinImageUrl,\n mapTypeControl,\n pinLabel,\n });\n }, [\n runtime,\n branches,\n selectedBranchId,\n onSelect,\n shopperLocation,\n effectiveMaxZoom,\n pinImageUrl,\n mapTypeControl,\n pinLabel,\n ]);\n if (!browserKey) {\n return null;\n }\n const unavailable = branches.filter(\n (branch) => !validCoordinates(branch.coordinates),\n );\n return (\n <div {...props} data-branch-map=\"\">\n <div\n hidden={failed || !hasCoordinates}\n ref={element}\n data-branch-map-canvas=\"\"\n style={{ height: \"100%\" }}\n />\n {failure && renderLoadError ? (\n renderLoadError({\n error: failure.error,\n retry: () => setAttempt((count) => count + 1),\n })\n ) : failed || !hasCoordinates ? (\n <p role=\"status\">Map location unavailable</p>\n ) : null}\n {unavailable.map((branch) => (\n <button\n key={branch.id}\n type=\"button\"\n aria-pressed={branch.id === selectedBranchId}\n onClick={() => onSelect(branch)}\n >\n {branch.name}: Map location unavailable\n </button>\n ))}\n {runtime && validCoordinates(shopperLocation) ? (\n <button type=\"button\" onClick={() => runtime.locate(shopperLocation)}>\n Use my current location\n </button>\n ) : null}\n </div>\n );\n}\n"],
|
|
5
|
+
"mappings": ";;;AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAsID,cAeE,YAfF;AAvFC,SAAS,UAAU;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,iBAAiB;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmB;AACjB,QAAM,UAAU,OAAuB,IAAI;AAC3C,QAAM,CAAC,SAAS,UAAU,IACxB,SAAgD;AAClD,QAAM,CAAC,SAAS,UAAU,IAAI,SAA6B;AAC3D,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,CAAC;AACxC,QAAM,cAAc,OAAO,WAAW;AACtC,YAAU,MAAM;AACd,gBAAY,UAAU;AAAA,EACxB,GAAG,CAAC,WAAW,CAAC;AAChB,QAAM,SAAS,YAAY;AAC3B,QAAM,mBACJ,YAAY,iBAAiB,eAAe,IAAI,KAAK;AACvD,QAAM,iBAAiB,gBAAgB,QAAQ,EAAE,SAAS;AAC1D,YAAU,MAAM;AACd,QAAI,CAAC,cAAc,CAAC,QAAQ,WAAW,CAAC,gBAAgB;AACtD;AAAA,IACF;AACA,QAAI,WAAW;AACf,UAAM,aAAa,IAAI,gBAAgB;AACvC,QAAI;AACJ,UAAM,SAAS,QAAQ;AACvB,eAAW,MAAS;AACpB,SAAK,OAAO,cAAW,EACpB;AAAA,MAAK,CAAC,EAAE,UAAU,MACjB,UAAU,QAAQ,YAAY,WAAW,QAAQ,EAAE,eAAe,CAAC;AAAA,IACrE,EACC,KAAK,CAAC,QAAQ;AACb,iBAAW;AACX,UAAI,UAAU;AACZ,YAAI,QAAQ;AAAA,MACd,OAAO;AACL,mBAAW,GAAG;AAAA,MAChB;AAAA,IACF,CAAC,EACA,MAAM,CAAC,UAAmB;AACzB,UAAI,CAAC,UAAU;AACb,mBAAW,EAAE,MAAM,CAAC;AACpB,oBAAY,UAAU,KAAK;AAAA,MAC7B;AAAA,IACF,CAAC;AACH,WAAO,MAAM;AACX,iBAAW;AACX,iBAAW,MAAM;AACjB,gBAAU,QAAQ;AAClB,iBAAW,MAAS;AAAA,IACtB;AAAA,EACF,GAAG,CAAC,YAAY,gBAAgB,OAAO,CAAC;AACxC,YAAU,MAAM;AACd,aAAS,OAAO,UAAU,kBAAkB,UAAU,iBAAiB;AAAA,MACrE,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH,GAAG;AAAA,IACD;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AACD,MAAI,CAAC,YAAY;AACf,WAAO;AAAA,EACT;AACA,QAAM,cAAc,SAAS;AAAA,IAC3B,CAAC,WAAW,CAAC,iBAAiB,OAAO,WAAW;AAAA,EAClD;AACA,SACE,qBAAC,SAAK,GAAG,OAAO,mBAAgB,IAC9B;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,QAAQ,UAAU,CAAC;AAAA,QACnB,KAAK;AAAA,QACL,0BAAuB;AAAA,QACvB,OAAO,EAAE,QAAQ,OAAO;AAAA;AAAA,IAC1B;AAAA,IACC,WAAW,kBACV,gBAAgB;AAAA,MACd,OAAO,QAAQ;AAAA,MACf,OAAO,MAAM,WAAW,CAAC,UAAU,QAAQ,CAAC;AAAA,IAC9C,CAAC,IACC,UAAU,CAAC,iBACb,oBAAC,OAAE,MAAK,UAAS,sCAAwB,IACvC;AAAA,IACH,YAAY,IAAI,CAAC,WAChB;AAAA,MAAC;AAAA;AAAA,QAEC,MAAK;AAAA,QACL,gBAAc,OAAO,OAAO;AAAA,QAC5B,SAAS,MAAM,SAAS,MAAM;AAAA,QAE7B;AAAA,iBAAO;AAAA,UAAK;AAAA;AAAA;AAAA,MALR,OAAO;AAAA,IAMd,CACD;AAAA,IACA,WAAW,iBAAiB,eAAe,IAC1C,oBAAC,YAAO,MAAK,UAAS,SAAS,MAAM,QAAQ,OAAO,eAAe,GAAG,qCAEtE,IACE;AAAA,KACN;AAEJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/branch-map/loader.ts
|
|
2
|
+
var pending;
|
|
3
|
+
function loadMaps(key) {
|
|
4
|
+
if (globalThis.google?.maps?.Map) {
|
|
5
|
+
return Promise.resolve(google.maps);
|
|
6
|
+
}
|
|
7
|
+
if (pending) {
|
|
8
|
+
return pending;
|
|
9
|
+
}
|
|
10
|
+
pending = new Promise((resolve, reject) => {
|
|
11
|
+
const script = document.createElement("script");
|
|
12
|
+
const callback = `__zappieMaps${Date.now()}`;
|
|
13
|
+
const scope = globalThis;
|
|
14
|
+
const cleanup = () => {
|
|
15
|
+
clearTimeout(timeout);
|
|
16
|
+
delete scope[callback];
|
|
17
|
+
};
|
|
18
|
+
const fail = () => {
|
|
19
|
+
cleanup();
|
|
20
|
+
script.remove();
|
|
21
|
+
pending = void 0;
|
|
22
|
+
reject(new Error("Map location unavailable"));
|
|
23
|
+
};
|
|
24
|
+
const timeout = setTimeout(fail, 15e3);
|
|
25
|
+
scope[callback] = () => {
|
|
26
|
+
cleanup();
|
|
27
|
+
resolve(google.maps);
|
|
28
|
+
};
|
|
29
|
+
script.async = true;
|
|
30
|
+
script.src = `https://maps.googleapis.com/maps/api/js?${new URLSearchParams({ key, loading: "async", callback, v: "weekly" })}`;
|
|
31
|
+
script.onerror = fail;
|
|
32
|
+
document.head.appendChild(script);
|
|
33
|
+
});
|
|
34
|
+
return pending;
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
loadMaps
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=loader.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/branch-map/loader.ts"],
|
|
4
|
+
"sourcesContent": ["/// <reference types=\"google.maps\" />\nlet pending: Promise<typeof google.maps> | undefined;\n\nexport function loadMaps(key: string): Promise<typeof google.maps> {\n if (globalThis.google?.maps?.Map) {\n return Promise.resolve(google.maps);\n }\n if (pending) {\n return pending;\n }\n pending = new Promise((resolve, reject) => {\n const script = document.createElement(\"script\");\n const callback = `__zappieMaps${Date.now()}`;\n const scope = globalThis as unknown as Record<string, unknown>;\n const cleanup = () => {\n clearTimeout(timeout);\n delete scope[callback];\n };\n const fail = () => {\n cleanup();\n script.remove();\n pending = undefined;\n reject(new Error(\"Map location unavailable\"));\n };\n const timeout = setTimeout(fail, 15000);\n scope[callback] = () => {\n cleanup();\n resolve(google.maps);\n };\n script.async = true;\n script.src = `https://maps.googleapis.com/maps/api/js?${new URLSearchParams({ key, loading: \"async\", callback, v: \"weekly\" })}`;\n script.onerror = fail;\n document.head.appendChild(script);\n });\n return pending;\n}\n"],
|
|
5
|
+
"mappings": ";AACA,IAAI;AAEG,SAAS,SAAS,KAA0C;AACjE,MAAI,WAAW,QAAQ,MAAM,KAAK;AAChC,WAAO,QAAQ,QAAQ,OAAO,IAAI;AAAA,EACpC;AACA,MAAI,SAAS;AACX,WAAO;AAAA,EACT;AACA,YAAU,IAAI,QAAQ,CAAC,SAAS,WAAW;AACzC,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,UAAM,WAAW,eAAe,KAAK,IAAI,CAAC;AAC1C,UAAM,QAAQ;AACd,UAAM,UAAU,MAAM;AACpB,mBAAa,OAAO;AACpB,aAAO,MAAM,QAAQ;AAAA,IACvB;AACA,UAAM,OAAO,MAAM;AACjB,cAAQ;AACR,aAAO,OAAO;AACd,gBAAU;AACV,aAAO,IAAI,MAAM,0BAA0B,CAAC;AAAA,IAC9C;AACA,UAAM,UAAU,WAAW,MAAM,IAAK;AACtC,UAAM,QAAQ,IAAI,MAAM;AACtB,cAAQ;AACR,cAAQ,OAAO,IAAI;AAAA,IACrB;AACA,WAAO,QAAQ;AACf,WAAO,MAAM,2CAA2C,IAAI,gBAAgB,EAAE,KAAK,SAAS,SAAS,UAAU,GAAG,SAAS,CAAC,CAAC;AAC7H,WAAO,UAAU;AACjB,aAAS,KAAK,YAAY,MAAM;AAAA,EAClC,CAAC;AACD,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/branch-map/runtime.ts
|
|
2
|
+
import {
|
|
3
|
+
branchBounds,
|
|
4
|
+
validCoordinates,
|
|
5
|
+
visibleBranches
|
|
6
|
+
} from "./geometry.js";
|
|
7
|
+
import { loadMaps } from "./loader.js";
|
|
8
|
+
var BRANCH_PADDING = 32;
|
|
9
|
+
var SHOPPER_PADDING = 88;
|
|
10
|
+
async function createMap(element, key, signal, options) {
|
|
11
|
+
const maps = await loadMaps(key);
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
const map = new maps.Map(element, {
|
|
14
|
+
center: { lat: 0, lng: 0 },
|
|
15
|
+
zoom: 12,
|
|
16
|
+
mapTypeControl: options.mapTypeControl,
|
|
17
|
+
streetViewControl: false
|
|
18
|
+
});
|
|
19
|
+
let pins = [];
|
|
20
|
+
let fitListener;
|
|
21
|
+
let fitted;
|
|
22
|
+
let lastSelected;
|
|
23
|
+
class Pin extends maps.OverlayView {
|
|
24
|
+
constructor(position, pin) {
|
|
25
|
+
super();
|
|
26
|
+
this.position = position;
|
|
27
|
+
const { label, selected, imageUrl } = pin;
|
|
28
|
+
const size = imageUrl ? `${selected ? 40 : 32}px` : "var(--branch-map-pin-size,24px)";
|
|
29
|
+
this.button = document.createElement("button");
|
|
30
|
+
this.button.type = "button";
|
|
31
|
+
this.button.setAttribute("aria-label", label);
|
|
32
|
+
this.button.setAttribute("aria-pressed", String(selected));
|
|
33
|
+
this.button.dataset.selected = String(selected);
|
|
34
|
+
this.button.dataset.branchMapPin = "";
|
|
35
|
+
this.button.style.cssText = imageUrl ? `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};padding:0;border-radius:50%;overflow:hidden;border:2px solid white;background:white;box-shadow:${selected ? "0 2px 6px rgb(0 0 0 / 35%)" : "0 1px 3px rgb(0 0 0 / 25%)"};cursor:pointer` : `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};border-radius:50%;border:2px solid var(--branch-map-pin-border,currentColor);background:var(${selected ? "--branch-map-pin-selected" : "--branch-map-pin"},currentColor);cursor:pointer`;
|
|
36
|
+
if (imageUrl) {
|
|
37
|
+
const image = document.createElement("img");
|
|
38
|
+
image.alt = "";
|
|
39
|
+
image.decoding = "async";
|
|
40
|
+
image.style.cssText = "display:block;width:100%;height:100%;object-fit:cover";
|
|
41
|
+
image.src = imageUrl;
|
|
42
|
+
this.button.appendChild(image);
|
|
43
|
+
}
|
|
44
|
+
if (pin.caption) {
|
|
45
|
+
this.caption = document.createElement("span");
|
|
46
|
+
this.caption.textContent = pin.caption;
|
|
47
|
+
this.caption.setAttribute("aria-hidden", "true");
|
|
48
|
+
this.caption.dataset.branchMapPinLabel = "";
|
|
49
|
+
this.caption.dataset.selected = String(selected);
|
|
50
|
+
this.caption.style.cssText = `position:absolute;transform:translate(-50%,calc(-100% - ${size} - 4px));white-space:nowrap;pointer-events:none;padding:2px 8px;border-radius:4px;font-size:12px;line-height:16px;font-weight:500;background:var(--branch-map-label-background,white);color:var(--branch-map-label-color,#111);border:1px solid var(--branch-map-label-border,transparent);box-shadow:0 1px 3px rgb(0 0 0 / 20%)`;
|
|
51
|
+
}
|
|
52
|
+
this.button.onclick = pin.onClick ?? null;
|
|
53
|
+
}
|
|
54
|
+
onAdd() {
|
|
55
|
+
const pane = this.getPanes()?.overlayMouseTarget;
|
|
56
|
+
pane?.appendChild(this.button);
|
|
57
|
+
if (this.caption) {
|
|
58
|
+
pane?.appendChild(this.caption);
|
|
59
|
+
}
|
|
60
|
+
maps.OverlayView.preventMapHitsAndGesturesFrom(this.button);
|
|
61
|
+
}
|
|
62
|
+
draw() {
|
|
63
|
+
const point = this.getProjection().fromLatLngToDivPixel(
|
|
64
|
+
new maps.LatLng(this.position)
|
|
65
|
+
);
|
|
66
|
+
if (point) {
|
|
67
|
+
for (const node of [this.button, this.caption]) {
|
|
68
|
+
if (node) {
|
|
69
|
+
node.style.left = `${point.x}px`;
|
|
70
|
+
node.style.top = `${point.y}px`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
onRemove() {
|
|
76
|
+
this.button.remove();
|
|
77
|
+
this.caption?.remove();
|
|
78
|
+
this.button.onclick = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const clear = () => {
|
|
82
|
+
pins.forEach((pin) => pin.setMap(null));
|
|
83
|
+
pins = [];
|
|
84
|
+
};
|
|
85
|
+
const fit = (branches, location, maxZoom) => {
|
|
86
|
+
fitListener?.remove();
|
|
87
|
+
fitListener = void 0;
|
|
88
|
+
const bounds = branchBounds(branches, location);
|
|
89
|
+
if (!bounds) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
map.fitBounds(bounds, location ? SHOPPER_PADDING : BRANCH_PADDING);
|
|
93
|
+
fitListener = maps.event.addListenerOnce(map, "idle", () => {
|
|
94
|
+
fitListener = void 0;
|
|
95
|
+
const zoom = map.getZoom();
|
|
96
|
+
if (typeof zoom === "number" && zoom > maxZoom) {
|
|
97
|
+
map.setZoom(maxZoom);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
update(branches, selected, onSelect, location, options2) {
|
|
103
|
+
clear();
|
|
104
|
+
map.setOptions({ mapTypeControl: options2?.mapTypeControl ?? false });
|
|
105
|
+
const visible = visibleBranches(branches);
|
|
106
|
+
for (const branch of visible) {
|
|
107
|
+
const isSelected = branch.id === selected;
|
|
108
|
+
const pin = new Pin(branch.coordinates, {
|
|
109
|
+
label: branch.name,
|
|
110
|
+
selected: isSelected,
|
|
111
|
+
imageUrl: options2?.pinImageUrl,
|
|
112
|
+
caption: options2?.pinLabel === "all" || options2?.pinLabel === "selected" && isSelected ? branch.name : void 0,
|
|
113
|
+
onClick: () => onSelect(branch)
|
|
114
|
+
});
|
|
115
|
+
pin.setMap(map);
|
|
116
|
+
pins.push(pin);
|
|
117
|
+
}
|
|
118
|
+
const shopper = validCoordinates(location) ? location : void 0;
|
|
119
|
+
if (shopper) {
|
|
120
|
+
const pin = new Pin(shopper, {
|
|
121
|
+
label: "Your location",
|
|
122
|
+
selected: false
|
|
123
|
+
});
|
|
124
|
+
pin.setMap(map);
|
|
125
|
+
pins.push(pin);
|
|
126
|
+
}
|
|
127
|
+
const maxZoom = options2?.maxZoom ?? 14;
|
|
128
|
+
const frame = JSON.stringify([
|
|
129
|
+
visible.map(({ coordinates }) => [coordinates.lat, coordinates.lng]),
|
|
130
|
+
shopper ? [shopper.lat, shopper.lng] : null,
|
|
131
|
+
maxZoom
|
|
132
|
+
]);
|
|
133
|
+
const previous = lastSelected;
|
|
134
|
+
lastSelected = selected;
|
|
135
|
+
if (frame !== fitted) {
|
|
136
|
+
fitted = frame;
|
|
137
|
+
fit(branches, shopper, maxZoom);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const target = visible.find((branch) => branch.id === selected);
|
|
141
|
+
if (target && selected !== previous) {
|
|
142
|
+
map.panTo(target.coordinates);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
locate(location) {
|
|
146
|
+
map.panTo(location);
|
|
147
|
+
},
|
|
148
|
+
destroy() {
|
|
149
|
+
fitListener?.remove();
|
|
150
|
+
clear();
|
|
151
|
+
maps.event.clearInstanceListeners(map);
|
|
152
|
+
element.replaceChildren();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
createMap
|
|
158
|
+
};
|
|
159
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/branch-map/runtime.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Branch } from \"@zappie/sdk\";\nimport {\n branchBounds,\n validCoordinates,\n visibleBranches,\n type Coordinates,\n} from \"./geometry\";\nimport { loadMaps } from \"./loader\";\n\n/** Padding around fitted branches, and around branches plus the shopper. */\nconst BRANCH_PADDING = 32;\nconst SHOPPER_PADDING = 88;\n\nexport type MapUpdateOptions = {\n maxZoom: number;\n pinImageUrl: string | undefined;\n mapTypeControl: boolean;\n pinLabel?: \"selected\" | \"all\" | undefined;\n};\n\ntype PinOptions = {\n label: string;\n selected: boolean;\n imageUrl?: string | undefined;\n /** Text shown above the pin. */\n caption?: string | undefined;\n onClick?: () => void;\n};\n\nexport async function createMap(\n element: HTMLElement,\n key: string,\n signal: AbortSignal,\n options: { mapTypeControl: boolean },\n) {\n const maps = await loadMaps(key);\n signal.throwIfAborted();\n const map = new maps.Map(element, {\n center: { lat: 0, lng: 0 },\n zoom: 12,\n mapTypeControl: options.mapTypeControl,\n streetViewControl: false,\n });\n let pins: google.maps.OverlayView[] = [];\n let fitListener: google.maps.MapsEventListener | undefined;\n let fitted: string | undefined;\n let lastSelected: string | undefined;\n class Pin extends maps.OverlayView {\n private button: HTMLButtonElement;\n private caption: HTMLElement | undefined;\n constructor(\n private position: Coordinates,\n pin: PinOptions,\n ) {\n super();\n const { label, selected, imageUrl } = pin;\n const size = imageUrl\n ? `${selected ? 40 : 32}px`\n : \"var(--branch-map-pin-size,24px)\";\n this.button = document.createElement(\"button\");\n this.button.type = \"button\";\n this.button.setAttribute(\"aria-label\", label);\n this.button.setAttribute(\"aria-pressed\", String(selected));\n this.button.dataset.selected = String(selected);\n this.button.dataset.branchMapPin = \"\";\n this.button.style.cssText = imageUrl\n ? `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};padding:0;border-radius:50%;overflow:hidden;border:2px solid white;background:white;box-shadow:${selected ? \"0 2px 6px rgb(0 0 0 / 35%)\" : \"0 1px 3px rgb(0 0 0 / 25%)\"};cursor:pointer`\n : `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};border-radius:50%;border:2px solid var(--branch-map-pin-border,currentColor);background:var(${selected ? \"--branch-map-pin-selected\" : \"--branch-map-pin\"},currentColor);cursor:pointer`;\n if (imageUrl) {\n const image = document.createElement(\"img\");\n image.alt = \"\";\n image.decoding = \"async\";\n image.style.cssText =\n \"display:block;width:100%;height:100%;object-fit:cover\";\n image.src = imageUrl;\n this.button.appendChild(image);\n }\n if (pin.caption) {\n // The button already carries the name, so the caption is visual only.\n this.caption = document.createElement(\"span\");\n this.caption.textContent = pin.caption;\n this.caption.setAttribute(\"aria-hidden\", \"true\");\n this.caption.dataset.branchMapPinLabel = \"\";\n this.caption.dataset.selected = String(selected);\n this.caption.style.cssText = `position:absolute;transform:translate(-50%,calc(-100% - ${size} - 4px));white-space:nowrap;pointer-events:none;padding:2px 8px;border-radius:4px;font-size:12px;line-height:16px;font-weight:500;background:var(--branch-map-label-background,white);color:var(--branch-map-label-color,#111);border:1px solid var(--branch-map-label-border,transparent);box-shadow:0 1px 3px rgb(0 0 0 / 20%)`;\n }\n this.button.onclick = pin.onClick ?? null;\n }\n onAdd() {\n const pane = this.getPanes()?.overlayMouseTarget;\n pane?.appendChild(this.button);\n if (this.caption) {\n pane?.appendChild(this.caption);\n }\n maps.OverlayView.preventMapHitsAndGesturesFrom(this.button);\n }\n draw() {\n const point = this.getProjection().fromLatLngToDivPixel(\n new maps.LatLng(this.position),\n );\n if (point) {\n for (const node of [this.button, this.caption]) {\n if (node) {\n node.style.left = `${point.x}px`;\n node.style.top = `${point.y}px`;\n }\n }\n }\n }\n onRemove() {\n this.button.remove();\n this.caption?.remove();\n this.button.onclick = null;\n }\n }\n const clear = () => {\n pins.forEach((pin) => pin.setMap(null));\n pins = [];\n };\n const fit = (\n branches: Branch[],\n location: Coordinates | undefined,\n maxZoom: number,\n ) => {\n fitListener?.remove();\n fitListener = undefined;\n const bounds = branchBounds(branches, location);\n if (!bounds) {\n return;\n }\n map.fitBounds(bounds, location ? SHOPPER_PADDING : BRANCH_PADDING);\n fitListener = maps.event.addListenerOnce(map, \"idle\", () => {\n fitListener = undefined;\n const zoom = map.getZoom();\n if (typeof zoom === \"number\" && zoom > maxZoom) {\n map.setZoom(maxZoom);\n }\n });\n };\n return {\n update(\n branches: Branch[],\n selected: string | undefined,\n onSelect: (branch: Branch) => void,\n location?: Coordinates,\n options?: MapUpdateOptions,\n ) {\n clear();\n map.setOptions({ mapTypeControl: options?.mapTypeControl ?? false });\n const visible = visibleBranches(branches);\n for (const branch of visible) {\n const isSelected = branch.id === selected;\n const pin = new Pin(branch.coordinates, {\n label: branch.name,\n selected: isSelected,\n imageUrl: options?.pinImageUrl,\n caption:\n options?.pinLabel === \"all\" ||\n (options?.pinLabel === \"selected\" && isSelected)\n ? branch.name\n : undefined,\n onClick: () => onSelect(branch),\n });\n pin.setMap(map);\n pins.push(pin);\n }\n const shopper = validCoordinates(location) ? location : undefined;\n if (shopper) {\n const pin = new Pin(shopper, {\n label: \"Your location\",\n selected: false,\n });\n pin.setMap(map);\n pins.push(pin);\n }\n const maxZoom = options?.maxZoom ?? 14;\n // Refit only when what the map frames changes. A new selection pans instead.\n const frame = JSON.stringify([\n visible.map(({ coordinates }) => [coordinates.lat, coordinates.lng]),\n shopper ? [shopper.lat, shopper.lng] : null,\n maxZoom,\n ]);\n const previous = lastSelected;\n lastSelected = selected;\n if (frame !== fitted) {\n fitted = frame;\n fit(branches, shopper, maxZoom);\n return;\n }\n const target = visible.find((branch) => branch.id === selected);\n if (target && selected !== previous) {\n map.panTo(target.coordinates);\n }\n },\n locate(location: Coordinates) {\n map.panTo(location);\n },\n destroy() {\n fitListener?.remove();\n clear();\n maps.event.clearInstanceListeners(map);\n element.replaceChildren();\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["options"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/deferred/index.tsx
|
|
4
|
+
import {
|
|
5
|
+
Fragment,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState
|
|
9
|
+
} from "react";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
function component(value) {
|
|
12
|
+
if (typeof value === "object" && value !== null && "default" in value && !("$$typeof" in value)) {
|
|
13
|
+
return value.default;
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
var ignore = () => {
|
|
18
|
+
};
|
|
19
|
+
function deferred(load) {
|
|
20
|
+
let loaded;
|
|
21
|
+
let pending;
|
|
22
|
+
const preload = () => {
|
|
23
|
+
if (loaded) {
|
|
24
|
+
return Promise.resolve(loaded);
|
|
25
|
+
}
|
|
26
|
+
if (!pending) {
|
|
27
|
+
const attempt = new Promise((resolve) => resolve(load())).then(
|
|
28
|
+
(value) => {
|
|
29
|
+
loaded = component(value);
|
|
30
|
+
return loaded;
|
|
31
|
+
},
|
|
32
|
+
(error) => {
|
|
33
|
+
if (pending === attempt) {
|
|
34
|
+
pending = void 0;
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
attempt.catch(ignore);
|
|
40
|
+
pending = attempt;
|
|
41
|
+
}
|
|
42
|
+
return pending;
|
|
43
|
+
};
|
|
44
|
+
const preloadWhenIdle = (options = {}) => {
|
|
45
|
+
if (typeof window === "undefined" || loaded) {
|
|
46
|
+
return ignore;
|
|
47
|
+
}
|
|
48
|
+
const start = () => {
|
|
49
|
+
preload().catch(ignore);
|
|
50
|
+
};
|
|
51
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
52
|
+
const handle2 = window.requestIdleCallback(
|
|
53
|
+
start,
|
|
54
|
+
options.timeout === void 0 ? void 0 : { timeout: options.timeout }
|
|
55
|
+
);
|
|
56
|
+
return () => window.cancelIdleCallback(handle2);
|
|
57
|
+
}
|
|
58
|
+
const handle = window.setTimeout(start, options.timeout ?? 1);
|
|
59
|
+
return () => window.clearTimeout(handle);
|
|
60
|
+
};
|
|
61
|
+
function DeferredComponent({ fallback = null, ...props }) {
|
|
62
|
+
const { open } = props;
|
|
63
|
+
const [ready, setReady] = useState(() => loaded);
|
|
64
|
+
const [opened, setOpened] = useState(open);
|
|
65
|
+
if (open && !opened) {
|
|
66
|
+
setOpened(true);
|
|
67
|
+
}
|
|
68
|
+
const Loaded = ready ?? loaded;
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!open || Loaded) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let active = true;
|
|
74
|
+
preload().then((value) => {
|
|
75
|
+
if (active) {
|
|
76
|
+
setReady(() => value);
|
|
77
|
+
}
|
|
78
|
+
}, ignore);
|
|
79
|
+
return () => {
|
|
80
|
+
active = false;
|
|
81
|
+
};
|
|
82
|
+
}, [open, Loaded]);
|
|
83
|
+
if (!(open || opened)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!Loaded) {
|
|
87
|
+
return open ? /* @__PURE__ */ jsx(Fragment, { children: fallback }) : null;
|
|
88
|
+
}
|
|
89
|
+
return /* @__PURE__ */ jsx(Loaded, { ...props });
|
|
90
|
+
}
|
|
91
|
+
DeferredComponent.displayName = "Deferred";
|
|
92
|
+
return {
|
|
93
|
+
// The load resolved to the component typed by T.
|
|
94
|
+
preload,
|
|
95
|
+
preloadWhenIdle,
|
|
96
|
+
Component: DeferredComponent
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function useIntentPreload(target) {
|
|
100
|
+
return useMemo(() => {
|
|
101
|
+
const start = () => {
|
|
102
|
+
target.preload().catch(ignore);
|
|
103
|
+
};
|
|
104
|
+
return { onPointerEnter: start, onPointerDown: start, onFocus: start };
|
|
105
|
+
}, [target]);
|
|
106
|
+
}
|
|
107
|
+
export {
|
|
108
|
+
deferred,
|
|
109
|
+
useIntentPreload
|
|
110
|
+
};
|
|
111
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/deferred/index.tsx"],
|
|
4
|
+
"sourcesContent": ["\"use client\";\nimport {\n Fragment,\n useEffect,\n useMemo,\n useState,\n type ComponentType,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\n/** Props for a deferred component: its own props plus `open`, which the helper reads and passes on. */\nexport type DeferredProps<P> = Omit<P, \"open\"> & {\n /**\n * Load the code if needed and render the component. Before the first open\n * it renders nothing; after it, the component stays mounted and receives\n * `open`, so it can animate out and return focus when it closes.\n */\n open: boolean;\n /** Shown while `open` is true and the code is still loading, for inline parts that should hold their place. */\n fallback?: ReactNode;\n};\n\n/** Options for `preloadWhenIdle`. */\nexport type DeferredIdleOptions = {\n /** Start the load after this many milliseconds even if the browser never goes idle. */\n timeout?: number;\n};\n\n/**\n * A component whose code loads on demand and renders without suspending.\n * Create it once at module scope with `deferred()`.\n */\nexport type Deferred<P> = {\n /**\n * Start loading the code. Repeated calls share one load. A failed load is\n * forgotten, so the next call tries again.\n * @returns The loaded component, or the load error.\n */\n preload(): Promise<ComponentType<P>>;\n /**\n * Load the code once the browser is idle. Use it only for small chunks.\n * Does nothing on the server.\n * @returns A function that cancels the scheduled load.\n */\n preloadWhenIdle(options?: DeferredIdleOptions): () => void;\n /**\n * Renders nothing until `open` is first true and the code is in. When\n * `open` turns true it loads the code and renders as soon as it arrives. It\n * never suspends. After the first open the component stays mounted and\n * every prop passes through, `open` included, so it can run its own exit.\n */\n Component: (props: DeferredProps<P>) => ReactElement | null;\n};\n\n/** Event handlers that start loading a deferred component when a shopper reaches for its trigger. */\nexport type IntentPreloadHandlers = {\n onPointerEnter: () => void;\n onPointerDown: () => void;\n onFocus: () => void;\n};\n\n/** What a deferred load resolves to: a component, or a module whose default export is one. */\nexport type DeferredModule =\n ComponentType<never> | { default: ComponentType<never> };\n\n/** The props of the component a deferred load resolves to. */\nexport type DeferredModuleProps<T> = T extends {\n default: ComponentType<infer P>;\n}\n ? P\n : T extends ComponentType<infer P>\n ? P\n : never;\n\ntype AnyComponent = ComponentType<Record<string, unknown>>;\n\nfunction component(value: DeferredModule): AnyComponent {\n if (\n typeof value === \"object\" &&\n value !== null &&\n \"default\" in value &&\n !(\"$$typeof\" in value)\n ) {\n return value.default as AnyComponent;\n }\n return value as AnyComponent;\n}\n\nconst ignore = () => {};\n\n/**\n * Load a component's code on demand without `React.lazy` or `<Suspense>`.\n * React holds a suspended boundary's first reveal for about 300 ms, so a\n * lazy overlay opens late. A deferred one renders on the frame its code\n * arrives. Pass a module whose default export is the component, or resolve\n * to the component itself.\n *\n * @example\n * const Bag = deferred(() => import(\"./bag-sheet\"));\n * const Finder = deferred(() => import(\"./finder\").then((m) => m.Finder));\n * <Bag.Component open={open} onClose={close} />\n */\nexport function deferred<T extends DeferredModule>(\n load: () => Promise<T>,\n): Deferred<DeferredModuleProps<T>> {\n type P = DeferredModuleProps<T>;\n let loaded: AnyComponent | undefined;\n let pending: Promise<AnyComponent> | undefined;\n\n const preload = (): Promise<AnyComponent> => {\n if (loaded) {\n return Promise.resolve(loaded);\n }\n if (!pending) {\n // Starts the import now; a load that throws becomes a rejection.\n const attempt = new Promise<T>((resolve) => resolve(load())).then(\n (value) => {\n loaded = component(value);\n return loaded;\n },\n (error: unknown) => {\n // Forget the failure so the next open or preload tries again.\n if (pending === attempt) {\n pending = undefined;\n }\n throw error;\n },\n );\n // Callers may ignore the promise; the component reports nothing either.\n attempt.catch(ignore);\n pending = attempt;\n }\n return pending;\n };\n\n const preloadWhenIdle = (options: DeferredIdleOptions = {}) => {\n if (typeof window === \"undefined\" || loaded) {\n return ignore;\n }\n const start = () => {\n preload().catch(ignore);\n };\n if (typeof window.requestIdleCallback === \"function\") {\n const handle = window.requestIdleCallback(\n start,\n options.timeout === undefined\n ? undefined\n : { timeout: options.timeout },\n );\n return () => window.cancelIdleCallback(handle);\n }\n const handle = window.setTimeout(start, options.timeout ?? 1);\n return () => window.clearTimeout(handle);\n };\n\n function DeferredComponent({ fallback = null, ...props }: DeferredProps<P>) {\n const { open } = props;\n const [ready, setReady] = useState<AnyComponent | undefined>(() => loaded);\n const [opened, setOpened] = useState<boolean>(open);\n if (open && !opened) {\n setOpened(true);\n }\n const Loaded = ready ?? loaded;\n useEffect(() => {\n if (!open || Loaded) {\n return;\n }\n let active = true;\n preload().then((value) => {\n if (active) {\n setReady(() => value);\n }\n }, ignore);\n return () => {\n active = false;\n };\n }, [open, Loaded]);\n if (!(open || opened)) {\n return null;\n }\n if (!Loaded) {\n return open ? <Fragment>{fallback}</Fragment> : null;\n }\n return <Loaded {...(props as Record<string, unknown>)} />;\n }\n DeferredComponent.displayName = \"Deferred\";\n\n return {\n // The load resolved to the component typed by T.\n preload: preload as () => Promise<ComponentType<P>>,\n preloadWhenIdle,\n Component: DeferredComponent,\n };\n}\n\n/**\n * Start loading a deferred component when the shopper points at, presses or\n * focuses its trigger, so the code is usually in by the click. Spread the\n * result on the trigger.\n *\n * @example\n * const intent = useIntentPreload(Bag);\n * <button {...intent} onClick={openBag}>Bag</button>\n */\nexport function useIntentPreload(target: {\n preload(): Promise<unknown>;\n}): IntentPreloadHandlers {\n return useMemo(() => {\n const start = () => {\n target.preload().catch(ignore);\n };\n return { onPointerEnter: start, onPointerDown: start, onFocus: start };\n }, [target]);\n}\n"],
|
|
5
|
+
"mappings": ";;;AACA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AA6Ka;AAzGpB,SAAS,UAAU,OAAqC;AACtD,MACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,EAAE,cAAc,QAChB;AACA,WAAO,MAAM;AAAA,EACf;AACA,SAAO;AACT;AAEA,IAAM,SAAS,MAAM;AAAC;AAcf,SAAS,SACd,MACkC;AAElC,MAAI;AACJ,MAAI;AAEJ,QAAM,UAAU,MAA6B;AAC3C,QAAI,QAAQ;AACV,aAAO,QAAQ,QAAQ,MAAM;AAAA,IAC/B;AACA,QAAI,CAAC,SAAS;AAEZ,YAAM,UAAU,IAAI,QAAW,CAAC,YAAY,QAAQ,KAAK,CAAC,CAAC,EAAE;AAAA,QAC3D,CAAC,UAAU;AACT,mBAAS,UAAU,KAAK;AACxB,iBAAO;AAAA,QACT;AAAA,QACA,CAAC,UAAmB;AAElB,cAAI,YAAY,SAAS;AACvB,sBAAU;AAAA,UACZ;AACA,gBAAM;AAAA,QACR;AAAA,MACF;AAEA,cAAQ,MAAM,MAAM;AACpB,gBAAU;AAAA,IACZ;AACA,WAAO;AAAA,EACT;AAEA,QAAM,kBAAkB,CAAC,UAA+B,CAAC,MAAM;AAC7D,QAAI,OAAO,WAAW,eAAe,QAAQ;AAC3C,aAAO;AAAA,IACT;AACA,UAAM,QAAQ,MAAM;AAClB,cAAQ,EAAE,MAAM,MAAM;AAAA,IACxB;AACA,QAAI,OAAO,OAAO,wBAAwB,YAAY;AACpD,YAAMA,UAAS,OAAO;AAAA,QACpB;AAAA,QACA,QAAQ,YAAY,SAChB,SACA,EAAE,SAAS,QAAQ,QAAQ;AAAA,MACjC;AACA,aAAO,MAAM,OAAO,mBAAmBA,OAAM;AAAA,IAC/C;AACA,UAAM,SAAS,OAAO,WAAW,OAAO,QAAQ,WAAW,CAAC;AAC5D,WAAO,MAAM,OAAO,aAAa,MAAM;AAAA,EACzC;AAEA,WAAS,kBAAkB,EAAE,WAAW,MAAM,GAAG,MAAM,GAAqB;AAC1E,UAAM,EAAE,KAAK,IAAI;AACjB,UAAM,CAAC,OAAO,QAAQ,IAAI,SAAmC,MAAM,MAAM;AACzE,UAAM,CAAC,QAAQ,SAAS,IAAI,SAAkB,IAAI;AAClD,QAAI,QAAQ,CAAC,QAAQ;AACnB,gBAAU,IAAI;AAAA,IAChB;AACA,UAAM,SAAS,SAAS;AACxB,cAAU,MAAM;AACd,UAAI,CAAC,QAAQ,QAAQ;AACnB;AAAA,MACF;AACA,UAAI,SAAS;AACb,cAAQ,EAAE,KAAK,CAAC,UAAU;AACxB,YAAI,QAAQ;AACV,mBAAS,MAAM,KAAK;AAAA,QACtB;AAAA,MACF,GAAG,MAAM;AACT,aAAO,MAAM;AACX,iBAAS;AAAA,MACX;AAAA,IACF,GAAG,CAAC,MAAM,MAAM,CAAC;AACjB,QAAI,EAAE,QAAQ,SAAS;AACrB,aAAO;AAAA,IACT;AACA,QAAI,CAAC,QAAQ;AACX,aAAO,OAAO,oBAAC,YAAU,oBAAS,IAAc;AAAA,IAClD;AACA,WAAO,oBAAC,UAAQ,GAAI,OAAmC;AAAA,EACzD;AACA,oBAAkB,cAAc;AAEhC,SAAO;AAAA;AAAA,IAEL;AAAA,IACA;AAAA,IACA,WAAW;AAAA,EACb;AACF;AAWO,SAAS,iBAAiB,QAEP;AACxB,SAAO,QAAQ,MAAM;AACnB,UAAM,QAAQ,MAAM;AAClB,aAAO,QAAQ,EAAE,MAAM,MAAM;AAAA,IAC/B;AACA,WAAO,EAAE,gBAAgB,OAAO,eAAe,OAAO,SAAS,MAAM;AAAA,EACvE,GAAG,CAAC,MAAM,CAAC;AACb;",
|
|
6
|
+
"names": ["handle"]
|
|
7
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { KeyboardEvent as KeyboardEvent$1, ImgHTMLAttributes, ChangeEvent, ClipboardEvent } from 'react';
|
|
3
|
-
import * as _zappie_sdk from '@zappie/sdk';
|
|
4
|
-
import { CartLine, CartLineInput, CartIssue, CartFix, Quote, DeliveryAddress, Branch as Branch$1, MenuSchedule, CartFulfilment, OptionGroup as OptionGroup$1, Item, Money as Money$1, Choice as Choice$1, MoneyFormatOptions, PaymentMethodKind, Category, Menu, OpeningTime } from '@zappie/sdk';
|
|
2
|
+
import { HTMLAttributes, ReactNode, ComponentType, ReactElement, KeyboardEvent as KeyboardEvent$1, ImgHTMLAttributes, ChangeEvent, ClipboardEvent } from 'react';
|
|
5
3
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
4
|
+
import * as _zappie_sdk from '@zappie/sdk';
|
|
5
|
+
import { Branch as Branch$1, CartLine, CartLineInput, CartIssue, CartFix, Quote, DeliveryAddress, MenuSchedule, CartFulfilment, OptionGroup as OptionGroup$1, Item, Money as Money$1, Choice as Choice$1, MoneyFormatOptions, PaymentMethodKind, Category, Menu, OpeningTime } from '@zappie/sdk';
|
|
6
6
|
import { useCart } from '@zappie/sdk/react';
|
|
7
7
|
import { P as PartProps, a as Presentation, S as SwipeDirection } from './engine-XTsgCwwe.js';
|
|
8
8
|
import { C as CloseOptions, O as OverlayQuery } from './history-Bqu12Vkz.js';
|
|
@@ -63,6 +63,52 @@ declare const Account: {
|
|
|
63
63
|
} & react.RefAttributes<HTMLElement>>;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
type Coordinates = {
|
|
67
|
+
lat: number;
|
|
68
|
+
lng: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** What a template gets to render when the map fails to load. */
|
|
72
|
+
type BranchMapLoadErrorState = {
|
|
73
|
+
/** Why the map failed to load. */
|
|
74
|
+
error: unknown;
|
|
75
|
+
/** Load the map again. */
|
|
76
|
+
retry: () => void;
|
|
77
|
+
};
|
|
78
|
+
/** Branches, selection and optional shopper coordinates for BranchMap. */
|
|
79
|
+
type BranchMapProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
|
|
80
|
+
branches: Branch$1[];
|
|
81
|
+
selectedBranchId?: string;
|
|
82
|
+
onSelect: (branch: Branch$1) => void;
|
|
83
|
+
/** The shopper's coordinates. The map fits them with the branches. */
|
|
84
|
+
shopperLocation?: Coordinates;
|
|
85
|
+
browserKey?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Highest zoom applied after fitting. Defaults to 15 when a shopper location
|
|
88
|
+
* is shown and 14 otherwise.
|
|
89
|
+
*/
|
|
90
|
+
maxZoom?: number;
|
|
91
|
+
/** Image shown inside each branch pin. */
|
|
92
|
+
pinImageUrl?: string;
|
|
93
|
+
/** Show the map and satellite switch. Defaults to false. */
|
|
94
|
+
mapTypeControl?: boolean;
|
|
95
|
+
/** Show the branch name above the selected pin, or above every pin. Off by default. */
|
|
96
|
+
pinLabel?: "selected" | "all";
|
|
97
|
+
/** Called each time the map fails to load. */
|
|
98
|
+
onLoadError?: (error: unknown) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Render your own message when the map fails to load, such as a retry
|
|
101
|
+
* button that calls `retry`. Without it the map shows "Map location unavailable".
|
|
102
|
+
*/
|
|
103
|
+
renderLoadError?: (state: BranchMapLoadErrorState) => ReactNode;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Show branch locations. Maps loads after mount when you supply a browser key.
|
|
107
|
+
* Set the container height with CSS. Style pins with --branch-map-pin,
|
|
108
|
+
* --branch-map-pin-selected, --branch-map-pin-border and --branch-map-pin-size.
|
|
109
|
+
*/
|
|
110
|
+
declare function BranchMap({ branches, selectedBranchId, onSelect, shopperLocation, browserKey, maxZoom, pinImageUrl, mapTypeControl, pinLabel, onLoadError, renderLoadError, ...props }: BranchMapProps): react_jsx_runtime.JSX.Element | null;
|
|
111
|
+
|
|
66
112
|
/**
|
|
67
113
|
* The current quantity, bounds, step, disabled state and value setter exposed by a quantity control.
|
|
68
114
|
*/
|
|
@@ -174,6 +220,87 @@ declare const Totals: react.ForwardRefExoticComponent<Omit<react.AllHTMLAttribut
|
|
|
174
220
|
children?: react.ReactNode | ((state: TotalsState) => react.ReactNode);
|
|
175
221
|
} & react.RefAttributes<HTMLElement>>;
|
|
176
222
|
|
|
223
|
+
/** Props for a deferred component: its own props plus `open`, which the helper reads and passes on. */
|
|
224
|
+
type DeferredProps<P> = Omit<P, "open"> & {
|
|
225
|
+
/**
|
|
226
|
+
* Load the code if needed and render the component. Before the first open
|
|
227
|
+
* it renders nothing; after it, the component stays mounted and receives
|
|
228
|
+
* `open`, so it can animate out and return focus when it closes.
|
|
229
|
+
*/
|
|
230
|
+
open: boolean;
|
|
231
|
+
/** Shown while `open` is true and the code is still loading, for inline parts that should hold their place. */
|
|
232
|
+
fallback?: ReactNode;
|
|
233
|
+
};
|
|
234
|
+
/** Options for `preloadWhenIdle`. */
|
|
235
|
+
type DeferredIdleOptions = {
|
|
236
|
+
/** Start the load after this many milliseconds even if the browser never goes idle. */
|
|
237
|
+
timeout?: number;
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* A component whose code loads on demand and renders without suspending.
|
|
241
|
+
* Create it once at module scope with `deferred()`.
|
|
242
|
+
*/
|
|
243
|
+
type Deferred<P> = {
|
|
244
|
+
/**
|
|
245
|
+
* Start loading the code. Repeated calls share one load. A failed load is
|
|
246
|
+
* forgotten, so the next call tries again.
|
|
247
|
+
* @returns The loaded component, or the load error.
|
|
248
|
+
*/
|
|
249
|
+
preload(): Promise<ComponentType<P>>;
|
|
250
|
+
/**
|
|
251
|
+
* Load the code once the browser is idle. Use it only for small chunks.
|
|
252
|
+
* Does nothing on the server.
|
|
253
|
+
* @returns A function that cancels the scheduled load.
|
|
254
|
+
*/
|
|
255
|
+
preloadWhenIdle(options?: DeferredIdleOptions): () => void;
|
|
256
|
+
/**
|
|
257
|
+
* Renders nothing until `open` is first true and the code is in. When
|
|
258
|
+
* `open` turns true it loads the code and renders as soon as it arrives. It
|
|
259
|
+
* never suspends. After the first open the component stays mounted and
|
|
260
|
+
* every prop passes through, `open` included, so it can run its own exit.
|
|
261
|
+
*/
|
|
262
|
+
Component: (props: DeferredProps<P>) => ReactElement | null;
|
|
263
|
+
};
|
|
264
|
+
/** Event handlers that start loading a deferred component when a shopper reaches for its trigger. */
|
|
265
|
+
type IntentPreloadHandlers = {
|
|
266
|
+
onPointerEnter: () => void;
|
|
267
|
+
onPointerDown: () => void;
|
|
268
|
+
onFocus: () => void;
|
|
269
|
+
};
|
|
270
|
+
/** What a deferred load resolves to: a component, or a module whose default export is one. */
|
|
271
|
+
type DeferredModule = ComponentType<never> | {
|
|
272
|
+
default: ComponentType<never>;
|
|
273
|
+
};
|
|
274
|
+
/** The props of the component a deferred load resolves to. */
|
|
275
|
+
type DeferredModuleProps<T> = T extends {
|
|
276
|
+
default: ComponentType<infer P>;
|
|
277
|
+
} ? P : T extends ComponentType<infer P> ? P : never;
|
|
278
|
+
/**
|
|
279
|
+
* Load a component's code on demand without `React.lazy` or `<Suspense>`.
|
|
280
|
+
* React holds a suspended boundary's first reveal for about 300 ms, so a
|
|
281
|
+
* lazy overlay opens late. A deferred one renders on the frame its code
|
|
282
|
+
* arrives. Pass a module whose default export is the component, or resolve
|
|
283
|
+
* to the component itself.
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* const Bag = deferred(() => import("./bag-sheet"));
|
|
287
|
+
* const Finder = deferred(() => import("./finder").then((m) => m.Finder));
|
|
288
|
+
* <Bag.Component open={open} onClose={close} />
|
|
289
|
+
*/
|
|
290
|
+
declare function deferred<T extends DeferredModule>(load: () => Promise<T>): Deferred<DeferredModuleProps<T>>;
|
|
291
|
+
/**
|
|
292
|
+
* Start loading a deferred component when the shopper points at, presses or
|
|
293
|
+
* focuses its trigger, so the code is usually in by the click. Spread the
|
|
294
|
+
* result on the trigger.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* const intent = useIntentPreload(Bag);
|
|
298
|
+
* <button {...intent} onClick={openBag}>Bag</button>
|
|
299
|
+
*/
|
|
300
|
+
declare function useIntentPreload(target: {
|
|
301
|
+
preload(): Promise<unknown>;
|
|
302
|
+
}): IntentPreloadHandlers;
|
|
303
|
+
|
|
177
304
|
declare function useCombobox<T>(results: readonly T[], onSelect: (item: T) => void): {
|
|
178
305
|
setListId: react.Dispatch<react.SetStateAction<string>>;
|
|
179
306
|
registerOption: (index: number, id: string) => void;
|
|
@@ -396,6 +523,17 @@ type ItemFormContext = {
|
|
|
396
523
|
value: CartLineInput;
|
|
397
524
|
setValue: (value: CartLineInput) => void;
|
|
398
525
|
};
|
|
526
|
+
/**
|
|
527
|
+
* An option group that failed validation when the shopper tried to add, with its element and issues.
|
|
528
|
+
*/
|
|
529
|
+
type ItemFormInvalidGroup = {
|
|
530
|
+
groupId: string;
|
|
531
|
+
/** The item's option group, or undefined for a group the template rendered from its own model. */
|
|
532
|
+
group: ActiveGroup | undefined;
|
|
533
|
+
/** The group's rendered element, or undefined when the group is not on screen. */
|
|
534
|
+
element: HTMLElement | undefined;
|
|
535
|
+
issues: CartIssue[];
|
|
536
|
+
};
|
|
399
537
|
/**
|
|
400
538
|
* Item form configuration with controlled or default selections and cart commit callbacks.
|
|
401
539
|
* Supply onCommit to update an existing line instead of adding a new one.
|
|
@@ -409,8 +547,15 @@ type ItemFormProps = PartProps<ItemFormContext> & {
|
|
|
409
547
|
onAdded?: (line: CartLine) => void;
|
|
410
548
|
/** Update an existing line through the same validation and focus workflow. */
|
|
411
549
|
onCommit?: (input: CartLineInput) => CartLine;
|
|
412
|
-
|
|
413
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Called when an add fails validation, after the first invalid group gets
|
|
552
|
+
* focus without scrolling. Groups are in display order. Scroll to
|
|
553
|
+
* `groups[0].element` yourself, for example smoothly. The list is empty
|
|
554
|
+
* when no issue belongs to a group.
|
|
555
|
+
*/
|
|
556
|
+
onInvalid?: (groups: ItemFormInvalidGroup[]) => void;
|
|
557
|
+
};
|
|
558
|
+
declare function Root$3({ item, value, defaultValue, onValueChange, defaultQuantity, onAdded, onCommit, onInvalid, ...props }: ItemFormProps): react_jsx_runtime.JSX.Element;
|
|
414
559
|
|
|
415
560
|
type GroupState = {
|
|
416
561
|
group: OptionGroup$1;
|
|
@@ -805,4 +950,4 @@ declare const OpenStatus: react.ForwardRefExoticComponent<Omit<react.AllHTMLAttr
|
|
|
805
950
|
/** The installed @zappie/foundations version. */
|
|
806
951
|
declare const FOUNDATIONS_VERSION: string;
|
|
807
952
|
|
|
808
|
-
export { Account, AccountNotConfiguredError, type ActiveGroup, Address, type AddressState, type AddressSuggestion, Branch, Cart, Choice, type Customer, FOUNDATIONS_VERSION, Fulfilment, Image, type ImageProps, ItemForm, type ItemFormContext, type ItemFormProps, Money, OpenStatus, OptionGroup, Otp, Overlay, PartProps, type PaymentMethod, PaymentMethods, Quantity, type QuantityProps, type QuantityState, Schedule, type ScheduleSlot, type ScheduleState, Search, type SearchResult, type SearchSource, type SearchState, type SignInOptions, Totals, type TotalsState, createSearchIndex, formatOpeningHours, formatWallClock, getOpenStatus, parseWallClock, scheduleSlots, useControllableState, useOverlay };
|
|
953
|
+
export { Account, AccountNotConfiguredError, type ActiveGroup, Address, type AddressState, type AddressSuggestion, Branch, BranchMap, type BranchMapLoadErrorState, type BranchMapProps, Cart, Choice, type Customer, type Deferred, type DeferredIdleOptions, type DeferredProps, FOUNDATIONS_VERSION, Fulfilment, Image, type ImageProps, type IntentPreloadHandlers, ItemForm, type ItemFormContext, type ItemFormInvalidGroup, type ItemFormProps, Money, OpenStatus, OptionGroup, Otp, Overlay, PartProps, type PaymentMethod, PaymentMethods, Quantity, type QuantityProps, type QuantityState, Schedule, type ScheduleSlot, type ScheduleState, Search, type SearchResult, type SearchSource, type SearchState, type SignInOptions, Totals, type TotalsState, createSearchIndex, deferred, formatOpeningHours, formatWallClock, getOpenStatus, parseWallClock, scheduleSlots, useControllableState, useIntentPreload, useOverlay };
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { PACKAGE_VERSION } from "./version.js";
|
|
5
5
|
import { Account, AccountNotConfiguredError } from "./account/index.js";
|
|
6
|
+
import { BranchMap } from "./branch-map/index.js";
|
|
6
7
|
import { Cart, Totals } from "./cart/index.js";
|
|
8
|
+
import { deferred, useIntentPreload } from "./deferred/index.js";
|
|
7
9
|
import { Address } from "./fulfilment/address-picker.js";
|
|
8
10
|
import { Branch } from "./fulfilment/branch-picker.js";
|
|
9
11
|
import { Schedule, scheduleSlots } from "./fulfilment/schedule.js";
|
|
@@ -29,6 +31,7 @@ export {
|
|
|
29
31
|
AccountNotConfiguredError,
|
|
30
32
|
Address,
|
|
31
33
|
Branch,
|
|
34
|
+
BranchMap,
|
|
32
35
|
Cart,
|
|
33
36
|
FOUNDATIONS_VERSION,
|
|
34
37
|
Fulfilment,
|
|
@@ -42,12 +45,14 @@ export {
|
|
|
42
45
|
Search,
|
|
43
46
|
Totals,
|
|
44
47
|
createSearchIndex,
|
|
48
|
+
deferred,
|
|
45
49
|
formatOpeningHours,
|
|
46
50
|
formatWallClock,
|
|
47
51
|
getOpenStatus,
|
|
48
52
|
parseWallClock,
|
|
49
53
|
scheduleSlots,
|
|
50
54
|
useControllableState,
|
|
55
|
+
useIntentPreload,
|
|
51
56
|
useOverlay
|
|
52
57
|
};
|
|
53
58
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\";\nimport { PACKAGE_VERSION } from \"./version\";\n\n/** The installed @zappie/foundations version. */\nexport const FOUNDATIONS_VERSION: string = PACKAGE_VERSION;\nexport { Account, AccountNotConfiguredError } from \"./account\";\nexport type { Customer, SignInOptions } from \"./account\";\nexport { Cart, Totals } from \"./cart\";\nexport type { TotalsState } from \"./cart\";\nexport { Address } from \"./fulfilment/address-picker\";\nexport type {\n AddressState,\n AddressSuggestion,\n} from \"./fulfilment/address-picker\";\nexport { Branch } from \"./fulfilment/branch-picker\";\nexport { Schedule, scheduleSlots } from \"./fulfilment/schedule\";\nexport type { ScheduleSlot, ScheduleState } from \"./fulfilment/schedule\";\nexport { Fulfilment } from \"./fulfilment/switch\";\nexport type { PartProps } from \"./internal/slot\";\nexport { useControllableState } from \"./internal/use-controllable-state\";\nexport * from \"./item-form\";\nexport { Image } from \"./media/image\";\nexport type { ImageProps } from \"./media/image\";\nexport { Money } from \"./money\";\nexport { Otp } from \"./otp/input\";\nexport { Overlay, useOverlay } from \"./overlay/react\";\nexport { PaymentMethods } from \"./payment/methods\";\nexport type { PaymentMethod } from \"./payment/methods\";\nexport { createSearchIndex, Search } from \"./search\";\nexport type { SearchResult, SearchSource, SearchState } from \"./search\";\nexport {\n formatOpeningHours,\n formatWallClock,\n getOpenStatus,\n parseWallClock,\n} from \"./status/hours\";\nexport { OpenStatus } from \"./status/open-status\";\n"],
|
|
5
|
-
"mappings": ";;;AACA,SAAS,uBAAuB;AAIhC,SAAS,SAAS,iCAAiC;AAEnD,SAAS,MAAM,cAAc;AAE7B,SAAS,eAAe;AAKxB,SAAS,cAAc;AACvB,SAAS,UAAU,qBAAqB;AAExC,SAAS,kBAAkB;AAE3B,SAAS,4BAA4B;AACrC,cAAc;AACd,SAAS,aAAa;AAEtB,SAAS,aAAa;AACtB,SAAS,WAAW;AACpB,SAAS,SAAS,kBAAkB;AACpC,SAAS,sBAAsB;AAE/B,SAAS,mBAAmB,cAAc;AAE1C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,kBAAkB;
|
|
4
|
+
"sourcesContent": ["\"use client\";\nimport { PACKAGE_VERSION } from \"./version\";\n\n/** The installed @zappie/foundations version. */\nexport const FOUNDATIONS_VERSION: string = PACKAGE_VERSION;\nexport { Account, AccountNotConfiguredError } from \"./account\";\nexport type { Customer, SignInOptions } from \"./account\";\nexport { BranchMap } from \"./branch-map\";\nexport type { BranchMapLoadErrorState, BranchMapProps } from \"./branch-map\";\nexport { Cart, Totals } from \"./cart\";\nexport type { TotalsState } from \"./cart\";\nexport { deferred, useIntentPreload } from \"./deferred\";\nexport type {\n Deferred,\n DeferredIdleOptions,\n DeferredProps,\n IntentPreloadHandlers,\n} from \"./deferred\";\nexport { Address } from \"./fulfilment/address-picker\";\nexport type {\n AddressState,\n AddressSuggestion,\n} from \"./fulfilment/address-picker\";\nexport { Branch } from \"./fulfilment/branch-picker\";\nexport { Schedule, scheduleSlots } from \"./fulfilment/schedule\";\nexport type { ScheduleSlot, ScheduleState } from \"./fulfilment/schedule\";\nexport { Fulfilment } from \"./fulfilment/switch\";\nexport type { PartProps } from \"./internal/slot\";\nexport { useControllableState } from \"./internal/use-controllable-state\";\nexport * from \"./item-form\";\nexport { Image } from \"./media/image\";\nexport type { ImageProps } from \"./media/image\";\nexport { Money } from \"./money\";\nexport { Otp } from \"./otp/input\";\nexport { Overlay, useOverlay } from \"./overlay/react\";\nexport { PaymentMethods } from \"./payment/methods\";\nexport type { PaymentMethod } from \"./payment/methods\";\nexport { createSearchIndex, Search } from \"./search\";\nexport type { SearchResult, SearchSource, SearchState } from \"./search\";\nexport {\n formatOpeningHours,\n formatWallClock,\n getOpenStatus,\n parseWallClock,\n} from \"./status/hours\";\nexport { OpenStatus } from \"./status/open-status\";\n"],
|
|
5
|
+
"mappings": ";;;AACA,SAAS,uBAAuB;AAIhC,SAAS,SAAS,iCAAiC;AAEnD,SAAS,iBAAiB;AAE1B,SAAS,MAAM,cAAc;AAE7B,SAAS,UAAU,wBAAwB;AAO3C,SAAS,eAAe;AAKxB,SAAS,cAAc;AACvB,SAAS,UAAU,qBAAqB;AAExC,SAAS,kBAAkB;AAE3B,SAAS,4BAA4B;AACrC,cAAc;AACd,SAAS,aAAa;AAEtB,SAAS,aAAa;AACtB,SAAS,WAAW;AACpB,SAAS,SAAS,kBAAkB;AACpC,SAAS,sBAAsB;AAE/B,SAAS,mBAAmB,cAAc;AAE1C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,kBAAkB;AAzCpB,IAAM,sBAA8B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/item-form/index.ts"],
|
|
4
|
-
"sourcesContent": ["import { Add, Options } from \"./options\";\nimport { Root } from \"./root\";\n/**\n * Composable item configuration parts for selecting options, validating a line and adding it to the cart.\n * Use ItemForm.Root within your ZappieProvider and place Options and Add inside it.\n */\nexport const ItemForm = { Root, Options, Add };\nexport { Choice, OptionGroup } from \"./option-group\";\nexport { Quantity } from \"./quantity\";\nexport type { QuantityProps, QuantityState } from \"./quantity\";\nexport type {
|
|
4
|
+
"sourcesContent": ["import { Add, Options } from \"./options\";\nimport { Root } from \"./root\";\n/**\n * Composable item configuration parts for selecting options, validating a line and adding it to the cart.\n * Use ItemForm.Root within your ZappieProvider and place Options and Add inside it.\n */\nexport const ItemForm = { Root, Options, Add };\nexport { Choice, OptionGroup } from \"./option-group\";\nexport { Quantity } from \"./quantity\";\nexport type { QuantityProps, QuantityState } from \"./quantity\";\nexport type {\n ActiveGroup,\n ItemFormContext,\n ItemFormInvalidGroup,\n ItemFormProps,\n} from \"./root\";\n"],
|
|
5
5
|
"mappings": ";AAAA,SAAS,KAAK,eAAe;AAC7B,SAAS,YAAY;AAMrB,SAAS,QAAQ,mBAAmB;AACpC,SAAS,gBAAgB;AAFlB,IAAM,WAAW,EAAE,MAAM,SAAS,IAAI;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/item-form/root.js
CHANGED
|
@@ -36,6 +36,7 @@ function Root({
|
|
|
36
36
|
defaultQuantity,
|
|
37
37
|
onAdded,
|
|
38
38
|
onCommit,
|
|
39
|
+
onInvalid,
|
|
39
40
|
...props
|
|
40
41
|
}) {
|
|
41
42
|
const client = useZappie();
|
|
@@ -78,14 +79,30 @@ function Root({
|
|
|
78
79
|
(quantity) => setValue({ ...normalized, quantity }),
|
|
79
80
|
[setValue, normalized]
|
|
80
81
|
);
|
|
81
|
-
const focusIssue = useCallback(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
const focusIssue = useCallback(
|
|
83
|
+
(errors) => {
|
|
84
|
+
const issuesOf = (groupId) => errors.filter((issue) => issue.groupId === groupId);
|
|
85
|
+
const group = (groupId) => preview.groups.find((candidate) => candidate.id === groupId);
|
|
86
|
+
const rendered = [...registry.current].filter(([groupId]) => issuesOf(groupId).length > 0).map(([groupId, element]) => ({
|
|
87
|
+
groupId,
|
|
88
|
+
group: group(groupId),
|
|
89
|
+
element,
|
|
90
|
+
issues: issuesOf(groupId)
|
|
91
|
+
}));
|
|
92
|
+
const offscreen = preview.groups.filter(
|
|
93
|
+
(candidate) => !registry.current.has(candidate.id) && issuesOf(candidate.id).length > 0
|
|
94
|
+
).map((candidate) => ({
|
|
95
|
+
groupId: candidate.id,
|
|
96
|
+
group: candidate,
|
|
97
|
+
element: void 0,
|
|
98
|
+
issues: issuesOf(candidate.id)
|
|
99
|
+
}));
|
|
100
|
+
const invalid = [...rendered, ...offscreen];
|
|
101
|
+
invalid[0]?.element?.focus({ preventScroll: true });
|
|
102
|
+
onInvalid?.(invalid);
|
|
103
|
+
},
|
|
104
|
+
[preview.groups, onInvalid]
|
|
105
|
+
);
|
|
89
106
|
const add = useCallback(() => {
|
|
90
107
|
const result = client.cart.validate(normalized);
|
|
91
108
|
if (!result.ok) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/item-form/root.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\";\nimport {\n previewItem,\n ZappieCartError,\n type CartIssue,\n type CartLine,\n type CartLineInput,\n type Item,\n type Money,\n type OptionGroup,\n} from \"@zappie/sdk\";\nimport { useCartCatalogue, useZappie } from \"@zappie/sdk/react\";\nimport {\n createContext,\n useCallback,\n useContext,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { part, type PartProps } from \"../internal/slot\";\nimport { useControllableState } from \"../internal/use-controllable-state\";\n/**\n * An SDK option group with the effective rules exposed by the item preview.\n */\nexport type ActiveGroup = OptionGroup;\n/**\n * The current item selection, preview prices, validation issues and actions supplied to item form parts.\n */\nexport type ItemFormContext = {\n item: Item;\n groups: ActiveGroup[];\n quantity: number;\n setQuantity: (quantity: number) => void;\n unitPrice: Money;\n total: Money;\n issues: CartIssue[];\n canAdd: boolean;\n add: () => CartLine | null;\n status: \"incomplete\" | \"valid\" | \"added\";\n value: CartLineInput;\n setValue: (value: CartLineInput) => void;\n};\nexport const ItemFormStateContext = createContext<ItemFormContext | null>(null);\nexport function useItemForm() {\n const value = useContext(ItemFormStateContext);\n if (!value) {\n throw new Error(\"ItemForm parts require ItemForm.Root\");\n }\n return value;\n}\nexport const GroupRegistry = createContext<Map<string, HTMLElement> | null>(\n null,\n);\n/**\n * Item form configuration with controlled or default selections and cart commit callbacks.\n * Supply onCommit to update an existing line instead of adding a new one.\n */\nexport type ItemFormProps = PartProps<ItemFormContext> & {\n item: Item;\n value?: CartLineInput;\n defaultValue?: Partial<CartLineInput>;\n onValueChange?: (value: CartLineInput) => void;\n defaultQuantity?: number;\n onAdded?: (line: CartLine) => void;\n /** Update an existing line through the same validation and focus workflow. */\n onCommit?: (input: CartLineInput) => CartLine;\n};\nexport function Root({\n item,\n value,\n defaultValue,\n onValueChange,\n defaultQuantity,\n onAdded,\n onCommit,\n ...props\n}: ItemFormProps) {\n const client = useZappie();\n const menu = useCartCatalogue();\n const [input, setInput] = useControllableState({\n value,\n defaultValue: {\n ...defaultValue,\n itemId: item.id,\n quantity: defaultQuantity ?? defaultValue?.quantity ?? item.quantity.min,\n },\n onChange: onValueChange,\n });\n const registry = useRef(new Map<string, HTMLElement>());\n const [added, setAdded] = useState(false);\n // Errors show only after the shopper tries to add (PRD 05 5.3), then track live validation.\n const [attempted, setAttempted] = useState(false);\n const [failure, setFailure] = useState<CartIssue[] | null>(null);\n const normalized = useMemo(\n () => ({ ...input, itemId: item.id }),\n [input, item.id],\n );\n const preview = useMemo(\n () => previewItem(item, normalized),\n [item, normalized],\n );\n const validation = useMemo(\n () => client.cart.validate(normalized),\n [client, normalized, menu],\n );\n const issues =\n failure ?? (attempted && !validation.ok ? validation.issues : []);\n const setValue = useCallback(\n (next: CartLineInput) => {\n setAdded(false);\n setFailure(null);\n setInput({ ...next, itemId: item.id });\n },\n [setInput, item.id],\n );\n const setQuantity = useCallback(\n (quantity: number) => setValue({ ...normalized, quantity }),\n [setValue, normalized],\n );\n const focusIssue = useCallback((errors: CartIssue[]) => {\n
|
|
5
|
-
"mappings": ";;;AACA;AAAA,EACE;AAAA,EACA;AAAA,OAOK;AACP,SAAS,kBAAkB,iBAAiB;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,YAA4B;AACrC,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["\"use client\";\nimport {\n previewItem,\n ZappieCartError,\n type CartIssue,\n type CartLine,\n type CartLineInput,\n type Item,\n type Money,\n type OptionGroup,\n} from \"@zappie/sdk\";\nimport { useCartCatalogue, useZappie } from \"@zappie/sdk/react\";\nimport {\n createContext,\n useCallback,\n useContext,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { part, type PartProps } from \"../internal/slot\";\nimport { useControllableState } from \"../internal/use-controllable-state\";\n/**\n * An SDK option group with the effective rules exposed by the item preview.\n */\nexport type ActiveGroup = OptionGroup;\n/**\n * The current item selection, preview prices, validation issues and actions supplied to item form parts.\n */\nexport type ItemFormContext = {\n item: Item;\n groups: ActiveGroup[];\n quantity: number;\n setQuantity: (quantity: number) => void;\n unitPrice: Money;\n total: Money;\n issues: CartIssue[];\n canAdd: boolean;\n add: () => CartLine | null;\n status: \"incomplete\" | \"valid\" | \"added\";\n value: CartLineInput;\n setValue: (value: CartLineInput) => void;\n};\nexport const ItemFormStateContext = createContext<ItemFormContext | null>(null);\nexport function useItemForm() {\n const value = useContext(ItemFormStateContext);\n if (!value) {\n throw new Error(\"ItemForm parts require ItemForm.Root\");\n }\n return value;\n}\n/**\n * An option group that failed validation when the shopper tried to add, with its element and issues.\n */\nexport type ItemFormInvalidGroup = {\n groupId: string;\n /** The item's option group, or undefined for a group the template rendered from its own model. */\n group: ActiveGroup | undefined;\n /** The group's rendered element, or undefined when the group is not on screen. */\n element: HTMLElement | undefined;\n issues: CartIssue[];\n};\nexport const GroupRegistry = createContext<Map<string, HTMLElement> | null>(\n null,\n);\n/**\n * Item form configuration with controlled or default selections and cart commit callbacks.\n * Supply onCommit to update an existing line instead of adding a new one.\n */\nexport type ItemFormProps = PartProps<ItemFormContext> & {\n item: Item;\n value?: CartLineInput;\n defaultValue?: Partial<CartLineInput>;\n onValueChange?: (value: CartLineInput) => void;\n defaultQuantity?: number;\n onAdded?: (line: CartLine) => void;\n /** Update an existing line through the same validation and focus workflow. */\n onCommit?: (input: CartLineInput) => CartLine;\n /**\n * Called when an add fails validation, after the first invalid group gets\n * focus without scrolling. Groups are in display order. Scroll to\n * `groups[0].element` yourself, for example smoothly. The list is empty\n * when no issue belongs to a group.\n */\n onInvalid?: (groups: ItemFormInvalidGroup[]) => void;\n};\nexport function Root({\n item,\n value,\n defaultValue,\n onValueChange,\n defaultQuantity,\n onAdded,\n onCommit,\n onInvalid,\n ...props\n}: ItemFormProps) {\n const client = useZappie();\n const menu = useCartCatalogue();\n const [input, setInput] = useControllableState({\n value,\n defaultValue: {\n ...defaultValue,\n itemId: item.id,\n quantity: defaultQuantity ?? defaultValue?.quantity ?? item.quantity.min,\n },\n onChange: onValueChange,\n });\n const registry = useRef(new Map<string, HTMLElement>());\n const [added, setAdded] = useState(false);\n // Errors show only after the shopper tries to add (PRD 05 5.3), then track live validation.\n const [attempted, setAttempted] = useState(false);\n const [failure, setFailure] = useState<CartIssue[] | null>(null);\n const normalized = useMemo(\n () => ({ ...input, itemId: item.id }),\n [input, item.id],\n );\n const preview = useMemo(\n () => previewItem(item, normalized),\n [item, normalized],\n );\n const validation = useMemo(\n () => client.cart.validate(normalized),\n [client, normalized, menu],\n );\n const issues =\n failure ?? (attempted && !validation.ok ? validation.issues : []);\n const setValue = useCallback(\n (next: CartLineInput) => {\n setAdded(false);\n setFailure(null);\n setInput({ ...next, itemId: item.id });\n },\n [setInput, item.id],\n );\n const setQuantity = useCallback(\n (quantity: number) => setValue({ ...normalized, quantity }),\n [setValue, normalized],\n );\n const focusIssue = useCallback(\n (errors: CartIssue[]) => {\n const issuesOf = (groupId: string) =>\n errors.filter((issue) => issue.groupId === groupId);\n const group = (groupId: string) =>\n preview.groups.find((candidate) => candidate.id === groupId);\n // Rendered groups first, in the order they mounted, then any invalid group not on screen.\n const rendered = [...registry.current]\n .filter(([groupId]) => issuesOf(groupId).length > 0)\n .map(([groupId, element]) => ({\n groupId,\n group: group(groupId),\n element: element as HTMLElement | undefined,\n issues: issuesOf(groupId),\n }));\n const offscreen = preview.groups\n .filter(\n (candidate) =>\n !registry.current.has(candidate.id) &&\n issuesOf(candidate.id).length > 0,\n )\n .map((candidate) => ({\n groupId: candidate.id,\n group: candidate,\n element: undefined,\n issues: issuesOf(candidate.id),\n }));\n const invalid = [...rendered, ...offscreen];\n // Focus moves for keyboard and screen reader users; the template decides how to scroll.\n invalid[0]?.element?.focus({ preventScroll: true });\n onInvalid?.(invalid);\n },\n [preview.groups, onInvalid],\n );\n const add = useCallback(() => {\n const result = client.cart.validate(normalized);\n if (!result.ok) {\n setAttempted(true);\n setFailure(result.issues);\n focusIssue(result.issues);\n return null;\n }\n try {\n const line = onCommit\n ? onCommit(normalized)\n : client.cart.add(item, normalized);\n setAttempted(false);\n setAdded(true);\n onAdded?.(line);\n return line;\n } catch (error) {\n if (error instanceof ZappieCartError) {\n setAttempted(true);\n setFailure([...error.issues]);\n focusIssue([...error.issues]);\n return null;\n }\n throw error;\n }\n }, [client, normalized, item, onAdded, onCommit, focusIssue]);\n const context: ItemFormContext = useMemo(\n () => ({\n item,\n ...preview,\n quantity: normalized.quantity ?? item.quantity.min,\n setQuantity,\n issues,\n canAdd: validation.ok && !failure,\n add,\n status: added ? \"added\" : validation.ok ? \"valid\" : \"incomplete\",\n value: normalized,\n setValue,\n }),\n [\n item,\n preview,\n normalized,\n setQuantity,\n issues,\n validation.ok,\n failure,\n add,\n added,\n setValue,\n ],\n );\n return (\n <ItemFormStateContext.Provider value={context}>\n <GroupRegistry.Provider value={registry.current}>\n {part(\"div\", props, context, {\n \"data-zappie-item\": item.id,\n \"data-state\": issues.length ? \"invalid\" : \"valid\",\n \"data-sold-out\": item.isAvailable ? undefined : \"\",\n })}\n </GroupRegistry.Provider>\n </ItemFormStateContext.Provider>\n );\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/overlay/react.js
CHANGED
|
@@ -108,6 +108,27 @@ function useContextValue() {
|
|
|
108
108
|
}
|
|
109
109
|
var prefetch = () => void loadOverlayEngine().catch(() => {
|
|
110
110
|
});
|
|
111
|
+
function prefetchWhenIdle() {
|
|
112
|
+
if (typeof window === "undefined") {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const schedule = () => {
|
|
116
|
+
if (engineModule) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
120
|
+
window.requestIdleCallback(prefetch);
|
|
121
|
+
} else {
|
|
122
|
+
window.setTimeout(prefetch, 1500);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
if (document.readyState === "complete") {
|
|
126
|
+
schedule();
|
|
127
|
+
} else {
|
|
128
|
+
window.addEventListener("load", schedule, { once: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
prefetchWhenIdle();
|
|
111
132
|
function Root({
|
|
112
133
|
param,
|
|
113
134
|
id,
|
|
@@ -124,17 +145,6 @@ function Root({
|
|
|
124
145
|
const generated = useId();
|
|
125
146
|
const [contentId, setContentId] = useState(`zappie-overlay-${generated}`);
|
|
126
147
|
const trigger = useRef(null);
|
|
127
|
-
useEffect(() => {
|
|
128
|
-
if (engineModule) {
|
|
129
|
-
return void 0;
|
|
130
|
-
}
|
|
131
|
-
if (typeof window.requestIdleCallback === "function") {
|
|
132
|
-
const handle2 = window.requestIdleCallback(prefetch);
|
|
133
|
-
return () => window.cancelIdleCallback(handle2);
|
|
134
|
-
}
|
|
135
|
-
const handle = window.setTimeout(prefetch, 1500);
|
|
136
|
-
return () => window.clearTimeout(handle);
|
|
137
|
-
}, []);
|
|
138
148
|
const context = useMemo(
|
|
139
149
|
() => ({
|
|
140
150
|
...overlay,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/overlay/react.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\nimport { composeRefs, part, partId, type PartProps } from \"../internal/slot\";\nimport type { Presentation, SwipeDirection } from \"./engine\";\nimport {\n closeOverlay,\n locationSnapshot,\n openOverlay,\n subscribeLocation,\n type CloseOptions,\n type OverlayQuery,\n} from \"./history\";\n\ntype EngineModule = typeof import(\"./engine\");\nlet engineModule: EngineModule | undefined;\nlet enginePromise: Promise<EngineModule> | undefined;\n/**\n * Load the overlay engine (Base UI dialog and drawer). It is a separate chunk: Root prefetches\n * it when the browser is idle and on trigger hover/focus, so it is usually ready by first open.\n */\nexport function loadOverlayEngine(): Promise<EngineModule> {\n enginePromise ??= import(\"./engine\").then(\n (module) => (engineModule = module),\n (error: unknown) => {\n // A failed chunk load (e.g. offline) must not poison later opens.\n enginePromise = undefined;\n throw error;\n },\n );\n return enginePromise;\n}\n\n/**\n * Control an overlay whose open state is stored in a URL query parameter.\n *\n * @param param - The query parameter to read and update.\n * @param id - The value that identifies this overlay as open.\n * @param options - Fallback query values and query parameters to omit when closing.\n * @returns Reactive open state and history-backed open and close actions.\n */\nexport function useOverlay(param: string, id: string, options?: CloseOptions) {\n const search = useSyncExternalStore(\n subscribeLocation,\n locationSnapshot,\n () => \"\",\n );\n const optionsRef = useRef(options);\n optionsRef.current = options;\n const open = useCallback(\n (query?: OverlayQuery) => openOverlay(param, id, query),\n [param, id],\n );\n const close = useCallback(\n () => closeOverlay(param, id, optionsRef.current),\n [param, id],\n );\n return useMemo(\n () => ({\n isOpen: new URLSearchParams(search).get(param) === id,\n open,\n close,\n }),\n [search, param, id, open, close],\n );\n}\n\n/** Tailwind's default min-width breakpoints. */\nconst BREAKPOINTS = { sm: 640, md: 768, lg: 1024, xl: 1280 } as const;\nexport type ResponsivePresentation =\n | Presentation\n | ({ base: Presentation } & Partial<\n Record<keyof typeof BREAKPOINTS, Presentation>\n >);\n/** Choose a presentation using media queries. The server renders `base`. */\nfunction usePresentation(value: ResponsivePresentation): Presentation {\n // Serialise so inline objects don't resubscribe on every render.\n const key = typeof value === \"string\" ? value : JSON.stringify(value);\n const resolve = useCallback((): Presentation => {\n const parsed = (\n key.startsWith(\"{\") ? JSON.parse(key) : key\n ) as ResponsivePresentation;\n if (typeof parsed === \"string\") {\n return parsed;\n }\n let current = parsed.base;\n for (const [name, width] of Object.entries(BREAKPOINTS)) {\n const at = parsed[name as keyof typeof BREAKPOINTS];\n if (at && window.matchMedia(`(min-width: ${width}px)`).matches) {\n current = at;\n }\n }\n return current;\n }, [key]);\n const subscribe = useCallback(\n (onChange: () => void) => {\n if (!key.startsWith(\"{\")) {\n return () => {};\n }\n const queries = Object.values(BREAKPOINTS).map((width) =>\n window.matchMedia(`(min-width: ${width}px)`),\n );\n queries.forEach((query) => query.addEventListener(\"change\", onChange));\n return () =>\n queries.forEach((query) =>\n query.removeEventListener(\"change\", onChange),\n );\n },\n [key],\n );\n return useSyncExternalStore(subscribe, resolve, () =>\n typeof value === \"string\" ? value : value.base,\n );\n}\n\ntype OverlayState = ReturnType<typeof useOverlay>;\ntype OverlayContext = OverlayState & {\n presentation: Presentation;\n swipeDirection: SwipeDirection;\n contentId: string;\n setContentId: (id: string) => void;\n trigger: { current: HTMLElement | null };\n};\nconst Context = createContext<OverlayContext | null>(null);\nconst EngineContext = createContext<EngineModule | null>(null);\nfunction useContextValue() {\n const value = useContext(Context);\n if (!value) {\n throw new Error(\"Overlay parts require Overlay.Root\");\n }\n return value;\n}\nconst prefetch = () => void loadOverlayEngine().catch(() => {});\n\n/**\n * The URL is the only open state. The engine (Base UI) supplies the modal mechanics: portal,\n * focus scope, scroll lock, outside and Escape dismissal, exit presence and, for drawers and\n * sheets, swipe-to-dismiss. Every open or close it requests goes through the history core.\n */\nfunction Root({\n param,\n id,\n presentation = \"dialog\",\n swipeDirection,\n fallback,\n omitParams,\n navigation,\n ...props\n}: PartProps<OverlayState> & {\n param: string;\n id: string;\n /** Your URL adapter for overlays whose open state lives in the path. */\n navigation?: OverlayState;\n /** A `dialog`, swipeable `drawer` or `sheet`. Use `{ base, sm, md, lg, xl }` for responsive layouts. */\n presentation?: ResponsivePresentation;\n /** Swipe direction to dismiss. Defaults to `down` for drawers and `right` for sheets. */\n swipeDirection?: SwipeDirection;\n} & CloseOptions) {\n const queryOverlay = useOverlay(param, id, { fallback, omitParams });\n const overlay = navigation ?? queryOverlay;\n const resolved = usePresentation(presentation);\n const generated = useId();\n const [contentId, setContentId] = useState(`zappie-overlay-${generated}`);\n const trigger = useRef<HTMLElement | null>(null);\n useEffect(() => {\n if (engineModule) {\n return undefined;\n }\n if (typeof window.requestIdleCallback === \"function\") {\n const handle = window.requestIdleCallback(prefetch);\n return () => window.cancelIdleCallback(handle);\n }\n const handle = window.setTimeout(prefetch, 1500);\n return () => window.clearTimeout(handle);\n }, []);\n const context = useMemo(\n () => ({\n ...overlay,\n presentation: resolved,\n swipeDirection:\n swipeDirection ?? (resolved === \"sheet\" ? \"right\" : \"down\"),\n contentId,\n setContentId,\n trigger,\n }),\n [overlay, resolved, swipeDirection, contentId],\n );\n return (\n <Context.Provider value={context}>\n {part(\"div\", props, overlay, {\n \"data-state\": overlay.isOpen ? \"open\" : \"closed\",\n \"data-presentation\": resolved,\n })}\n </Context.Provider>\n );\n}\nconst Trigger = forwardRef<HTMLElement, PartProps<OverlayState>>(\n function Trigger(props, ref) {\n const ctx = useContextValue();\n return part(\n \"button\",\n props,\n ctx,\n {\n type: \"button\",\n \"aria-haspopup\": \"dialog\",\n \"aria-expanded\": ctx.isOpen,\n \"aria-controls\": ctx.contentId,\n \"data-state\": ctx.isOpen ? \"open\" : \"closed\",\n onPointerEnter: prefetch,\n onFocus: prefetch,\n onClick: () => ctx.open(),\n },\n composeRefs(ref, (node: HTMLElement | null) => {\n ctx.trigger.current = node;\n }),\n );\n },\n);\nexport type OverlayContentProps = PartProps<OverlayState> & {\n /** Runs on Escape. Call `event.preventDefault()` to keep the overlay open. */\n onEscape?: (event: KeyboardEvent) => void;\n /** Runs on an outside pointer press or focus. Call `event.preventDefault()` to keep the overlay open. */\n onInteractOutside?: (event: Event) => void;\n /** Keep the content mounted while closed, for exit animations driven by `data-state`. */\n forceMount?: true;\n /** Where to mount the content. Defaults to `document.body`. */\n container?: HTMLElement | null;\n};\nconst Content = forwardRef<HTMLElement, OverlayContentProps>(function Content(\n { onEscape, onInteractOutside, forceMount, container, ...props },\n ref,\n) {\n const ctx = useContextValue();\n const [engine, setEngine] = useState(engineModule);\n // Once opened, the surface stays mounted so the engine can play exit transitions.\n const [opened, setOpened] = useState(ctx.isOpen);\n if (ctx.isOpen && !opened) {\n setOpened(true);\n }\n const customId = partId(props as Parameters<typeof partId>[0]);\n const { setContentId } = ctx;\n useEffect(() => {\n if (customId) {\n setContentId(customId);\n }\n }, [customId, setContentId]);\n useEffect(() => {\n if (engine || !(ctx.isOpen || forceMount)) {\n return undefined;\n }\n let live = true;\n void loadOverlayEngine().then(\n (module) => {\n if (live) {\n setEngine(module);\n }\n },\n () => {},\n );\n return () => {\n live = false;\n };\n }, [engine, ctx.isOpen, forceMount]);\n if (!engine || (!opened && !forceMount)) {\n return null;\n }\n return (\n <EngineContext.Provider value={engine}>\n <engine.Surface\n open={ctx.isOpen}\n presentation={ctx.presentation}\n swipeDirection={ctx.swipeDirection}\n contentId={ctx.contentId}\n trigger={ctx.trigger}\n onOpen={() => ctx.open()}\n onClose={ctx.close}\n onEscape={onEscape}\n onInteractOutside={onInteractOutside}\n forceMount={forceMount}\n container={container}\n state={ctx}\n contentProps={props as PartProps<never>}\n contentRef={ref}\n />\n </EngineContext.Provider>\n );\n});\nfunction useEngine() {\n const engine = useContext(EngineContext);\n if (!engine) {\n throw new Error(\n \"Overlay.Title and Overlay.Close must be inside Overlay.Content\",\n );\n }\n return engine;\n}\nconst Title = forwardRef<HTMLElement, PartProps<OverlayState>>(\n function Title(props, ref) {\n const ctx = useContextValue();\n const { EngineTitle } = useEngine();\n return (\n <EngineTitle {...(props as PartProps<unknown>)} state={ctx} ref={ref} />\n );\n },\n);\nconst Close = forwardRef<HTMLElement, PartProps<OverlayState>>(\n function Close(props, ref) {\n const ctx = useContextValue();\n const { EngineClose } = useEngine();\n return (\n <EngineClose {...(props as PartProps<unknown>)} state={ctx} ref={ref} />\n );\n },\n);\n/**\n * Composable dialog, drawer and sheet parts with URL-backed open state and a lazy modal engine.\n * Use Root, Trigger, Content, Title and Close to connect history, focus handling and dismissal.\n */\nexport const Overlay = { Root, Trigger, Content, Title, Close };\n"],
|
|
5
|
-
"mappings": ";;;AACA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa,MAAM,cAA8B;AAE1D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;
|
|
6
|
-
"names": ["
|
|
4
|
+
"sourcesContent": ["\"use client\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\nimport { composeRefs, part, partId, type PartProps } from \"../internal/slot\";\nimport type { Presentation, SwipeDirection } from \"./engine\";\nimport {\n closeOverlay,\n locationSnapshot,\n openOverlay,\n subscribeLocation,\n type CloseOptions,\n type OverlayQuery,\n} from \"./history\";\n\ntype EngineModule = typeof import(\"./engine\");\nlet engineModule: EngineModule | undefined;\nlet enginePromise: Promise<EngineModule> | undefined;\n/**\n * Load the overlay engine (Base UI dialog and drawer). It is a separate chunk, prefetched once\n * the page has loaded and the browser is idle, and on trigger hover/focus, so it is ready by\n * the first open.\n */\nexport function loadOverlayEngine(): Promise<EngineModule> {\n enginePromise ??= import(\"./engine\").then(\n (module) => (engineModule = module),\n (error: unknown) => {\n // A failed chunk load (e.g. offline) must not poison later opens.\n enginePromise = undefined;\n throw error;\n },\n );\n return enginePromise;\n}\n\n/**\n * Control an overlay whose open state is stored in a URL query parameter.\n *\n * @param param - The query parameter to read and update.\n * @param id - The value that identifies this overlay as open.\n * @param options - Fallback query values and query parameters to omit when closing.\n * @returns Reactive open state and history-backed open and close actions.\n */\nexport function useOverlay(param: string, id: string, options?: CloseOptions) {\n const search = useSyncExternalStore(\n subscribeLocation,\n locationSnapshot,\n () => \"\",\n );\n const optionsRef = useRef(options);\n optionsRef.current = options;\n const open = useCallback(\n (query?: OverlayQuery) => openOverlay(param, id, query),\n [param, id],\n );\n const close = useCallback(\n () => closeOverlay(param, id, optionsRef.current),\n [param, id],\n );\n return useMemo(\n () => ({\n isOpen: new URLSearchParams(search).get(param) === id,\n open,\n close,\n }),\n [search, param, id, open, close],\n );\n}\n\n/** Tailwind's default min-width breakpoints. */\nconst BREAKPOINTS = { sm: 640, md: 768, lg: 1024, xl: 1280 } as const;\nexport type ResponsivePresentation =\n | Presentation\n | ({ base: Presentation } & Partial<\n Record<keyof typeof BREAKPOINTS, Presentation>\n >);\n/** Choose a presentation using media queries. The server renders `base`. */\nfunction usePresentation(value: ResponsivePresentation): Presentation {\n // Serialise so inline objects don't resubscribe on every render.\n const key = typeof value === \"string\" ? value : JSON.stringify(value);\n const resolve = useCallback((): Presentation => {\n const parsed = (\n key.startsWith(\"{\") ? JSON.parse(key) : key\n ) as ResponsivePresentation;\n if (typeof parsed === \"string\") {\n return parsed;\n }\n let current = parsed.base;\n for (const [name, width] of Object.entries(BREAKPOINTS)) {\n const at = parsed[name as keyof typeof BREAKPOINTS];\n if (at && window.matchMedia(`(min-width: ${width}px)`).matches) {\n current = at;\n }\n }\n return current;\n }, [key]);\n const subscribe = useCallback(\n (onChange: () => void) => {\n if (!key.startsWith(\"{\")) {\n return () => {};\n }\n const queries = Object.values(BREAKPOINTS).map((width) =>\n window.matchMedia(`(min-width: ${width}px)`),\n );\n queries.forEach((query) => query.addEventListener(\"change\", onChange));\n return () =>\n queries.forEach((query) =>\n query.removeEventListener(\"change\", onChange),\n );\n },\n [key],\n );\n return useSyncExternalStore(subscribe, resolve, () =>\n typeof value === \"string\" ? value : value.base,\n );\n}\n\ntype OverlayState = ReturnType<typeof useOverlay>;\ntype OverlayContext = OverlayState & {\n presentation: Presentation;\n swipeDirection: SwipeDirection;\n contentId: string;\n setContentId: (id: string) => void;\n trigger: { current: HTMLElement | null };\n};\nconst Context = createContext<OverlayContext | null>(null);\nconst EngineContext = createContext<EngineModule | null>(null);\nfunction useContextValue() {\n const value = useContext(Context);\n if (!value) {\n throw new Error(\"Overlay parts require Overlay.Root\");\n }\n return value;\n}\nconst prefetch = () => void loadOverlayEngine().catch(() => {});\n\n// The prefetch is scheduled when this module loads, not when an Overlay.Root mounts: a template\n// that loads its overlay with deferred() mounts the Root only on the tap, which would leave the\n// engine's download inside the open. Waiting for the load event keeps it out of the page load.\nfunction prefetchWhenIdle() {\n if (typeof window === \"undefined\") {\n return;\n }\n const schedule = () => {\n if (engineModule) {\n return;\n }\n if (typeof window.requestIdleCallback === \"function\") {\n window.requestIdleCallback(prefetch);\n } else {\n window.setTimeout(prefetch, 1500);\n }\n };\n if (document.readyState === \"complete\") {\n schedule();\n } else {\n window.addEventListener(\"load\", schedule, { once: true });\n }\n}\nprefetchWhenIdle();\n\n/**\n * The URL is the only open state. The engine (Base UI) supplies the modal mechanics: portal,\n * focus scope, scroll lock, outside and Escape dismissal, exit presence and, for drawers and\n * sheets, swipe-to-dismiss. Every open or close it requests goes through the history core.\n */\nfunction Root({\n param,\n id,\n presentation = \"dialog\",\n swipeDirection,\n fallback,\n omitParams,\n navigation,\n ...props\n}: PartProps<OverlayState> & {\n param: string;\n id: string;\n /** Your URL adapter for overlays whose open state lives in the path. */\n navigation?: OverlayState;\n /** A `dialog`, swipeable `drawer` or `sheet`. Use `{ base, sm, md, lg, xl }` for responsive layouts. */\n presentation?: ResponsivePresentation;\n /** Swipe direction to dismiss. Defaults to `down` for drawers and `right` for sheets. */\n swipeDirection?: SwipeDirection;\n} & CloseOptions) {\n const queryOverlay = useOverlay(param, id, { fallback, omitParams });\n const overlay = navigation ?? queryOverlay;\n const resolved = usePresentation(presentation);\n const generated = useId();\n const [contentId, setContentId] = useState(`zappie-overlay-${generated}`);\n const trigger = useRef<HTMLElement | null>(null);\n const context = useMemo(\n () => ({\n ...overlay,\n presentation: resolved,\n swipeDirection:\n swipeDirection ?? (resolved === \"sheet\" ? \"right\" : \"down\"),\n contentId,\n setContentId,\n trigger,\n }),\n [overlay, resolved, swipeDirection, contentId],\n );\n return (\n <Context.Provider value={context}>\n {part(\"div\", props, overlay, {\n \"data-state\": overlay.isOpen ? \"open\" : \"closed\",\n \"data-presentation\": resolved,\n })}\n </Context.Provider>\n );\n}\nconst Trigger = forwardRef<HTMLElement, PartProps<OverlayState>>(\n function Trigger(props, ref) {\n const ctx = useContextValue();\n return part(\n \"button\",\n props,\n ctx,\n {\n type: \"button\",\n \"aria-haspopup\": \"dialog\",\n \"aria-expanded\": ctx.isOpen,\n \"aria-controls\": ctx.contentId,\n \"data-state\": ctx.isOpen ? \"open\" : \"closed\",\n onPointerEnter: prefetch,\n onFocus: prefetch,\n onClick: () => ctx.open(),\n },\n composeRefs(ref, (node: HTMLElement | null) => {\n ctx.trigger.current = node;\n }),\n );\n },\n);\nexport type OverlayContentProps = PartProps<OverlayState> & {\n /** Runs on Escape. Call `event.preventDefault()` to keep the overlay open. */\n onEscape?: (event: KeyboardEvent) => void;\n /** Runs on an outside pointer press or focus. Call `event.preventDefault()` to keep the overlay open. */\n onInteractOutside?: (event: Event) => void;\n /** Keep the content mounted while closed, for exit animations driven by `data-state`. */\n forceMount?: true;\n /** Where to mount the content. Defaults to `document.body`. */\n container?: HTMLElement | null;\n};\nconst Content = forwardRef<HTMLElement, OverlayContentProps>(function Content(\n { onEscape, onInteractOutside, forceMount, container, ...props },\n ref,\n) {\n const ctx = useContextValue();\n const [engine, setEngine] = useState(engineModule);\n // Once opened, the surface stays mounted so the engine can play exit transitions.\n const [opened, setOpened] = useState(ctx.isOpen);\n if (ctx.isOpen && !opened) {\n setOpened(true);\n }\n const customId = partId(props as Parameters<typeof partId>[0]);\n const { setContentId } = ctx;\n useEffect(() => {\n if (customId) {\n setContentId(customId);\n }\n }, [customId, setContentId]);\n useEffect(() => {\n if (engine || !(ctx.isOpen || forceMount)) {\n return undefined;\n }\n let live = true;\n void loadOverlayEngine().then(\n (module) => {\n if (live) {\n setEngine(module);\n }\n },\n () => {},\n );\n return () => {\n live = false;\n };\n }, [engine, ctx.isOpen, forceMount]);\n if (!engine || (!opened && !forceMount)) {\n return null;\n }\n return (\n <EngineContext.Provider value={engine}>\n <engine.Surface\n open={ctx.isOpen}\n presentation={ctx.presentation}\n swipeDirection={ctx.swipeDirection}\n contentId={ctx.contentId}\n trigger={ctx.trigger}\n onOpen={() => ctx.open()}\n onClose={ctx.close}\n onEscape={onEscape}\n onInteractOutside={onInteractOutside}\n forceMount={forceMount}\n container={container}\n state={ctx}\n contentProps={props as PartProps<never>}\n contentRef={ref}\n />\n </EngineContext.Provider>\n );\n});\nfunction useEngine() {\n const engine = useContext(EngineContext);\n if (!engine) {\n throw new Error(\n \"Overlay.Title and Overlay.Close must be inside Overlay.Content\",\n );\n }\n return engine;\n}\nconst Title = forwardRef<HTMLElement, PartProps<OverlayState>>(\n function Title(props, ref) {\n const ctx = useContextValue();\n const { EngineTitle } = useEngine();\n return (\n <EngineTitle {...(props as PartProps<unknown>)} state={ctx} ref={ref} />\n );\n },\n);\nconst Close = forwardRef<HTMLElement, PartProps<OverlayState>>(\n function Close(props, ref) {\n const ctx = useContextValue();\n const { EngineClose } = useEngine();\n return (\n <EngineClose {...(props as PartProps<unknown>)} state={ctx} ref={ref} />\n );\n },\n);\n/**\n * Composable dialog, drawer and sheet parts with URL-backed open state and a lazy modal engine.\n * Use Root, Trigger, Content, Title and Close to connect history, focus handling and dismissal.\n */\nexport const Overlay = { Root, Trigger, Content, Title, Close };\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["Trigger", "Content", "Title", "Close"]
|
|
7
7
|
}
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zappie/foundations",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "UI-less React primitives for Zappie storefronts: behaviour, state and accessibility without styles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@base-ui/react": "^1.8.0",
|
|
35
|
-
"@zappie/sdk": "^0.1.
|
|
35
|
+
"@zappie/sdk": "^0.1.8"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": "^18.2.0 || ^19.0.0",
|