@occluze/tether 0.6.8 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/presets/barba.esm.js +19 -23
- package/dist/tether.account-pages.js +320 -365
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +242 -491
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +66 -88
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +36 -33
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +28 -36
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +1270 -894
- package/dist/tether.markets.js +73 -90
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +44 -68
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +24 -23
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/{order-lines.js → order-lines.ts} +20 -41
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/{page-events.js → page-events.ts} +55 -90
- package/src/account/server-client.ts +183 -0
- package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/{account-pages.js → account-pages.ts} +29 -37
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
- package/src/analytics.ts +315 -0
- package/src/{api-version.js → api-version.ts} +25 -31
- package/src/{astro.js → astro.ts} +110 -104
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/{drawer.js → drawer.ts} +18 -31
- package/src/cart/mutations.ts +237 -0
- package/src/cart/{render.js → render.ts} +68 -96
- package/src/cart/store.ts +279 -0
- package/src/{cart.js → cart.ts} +82 -128
- package/src/client.ts +391 -0
- package/src/{collection.js → collection.ts} +30 -38
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
- package/src/controls/{discount.js → discount.ts} +13 -34
- package/src/controls/{extras.js → extras.ts} +15 -24
- package/src/controls/{navigation.js → navigation.ts} +24 -30
- package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
- package/src/{diagnose.js → diagnose.ts} +95 -96
- package/src/dom.ts +268 -0
- package/src/{extras.js → extras.ts} +43 -52
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/{images.js → images.ts} +30 -43
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/{loader.js → loader.ts} +149 -123
- package/src/market-codes.ts +6 -0
- package/src/{markets-ui.js → markets-ui.ts} +91 -68
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/{mounting.js → mounting.ts} +88 -101
- package/src/{page-market.js → page-market.ts} +32 -38
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/{product-events.js → product-events.ts} +33 -48
- package/src/{product-render.js → product-render.ts} +38 -77
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/{product.js → product.ts} +90 -126
- package/src/{repeaters.js → repeaters.ts} +40 -52
- package/src/{search.js → search.ts} +85 -103
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/{shopify-values.js → shopify-values.ts} +24 -22
- package/src/{status.js → status.ts} +34 -54
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
- package/src/{variant-render.js → variant-render.ts} +24 -50
- package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
- package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
- package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
- package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
- package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
- package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
- package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
- package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
- package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
- package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
- package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
- package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
- package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
- package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
- package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
- package/dist/esm/chunks/search-OU75RNQL.js +0 -1
- package/src/account/addresses-view.js +0 -92
- package/src/account/browser-session.js +0 -359
- package/src/account/markup.js +0 -48
- package/src/account/operations.js +0 -180
- package/src/account/order-list.js +0 -91
- package/src/account/order-page.js +0 -192
- package/src/account/server-client.js +0 -172
- package/src/account/shared.js +0 -111
- package/src/account/shopify-auth.js +0 -150
- package/src/account-api.js +0 -118
- package/src/account-links.js +0 -35
- package/src/account.js +0 -342
- package/src/amounts.js +0 -36
- package/src/analytics.js +0 -352
- package/src/cart/checkout.js +0 -97
- package/src/cart/mutations.js +0 -234
- package/src/cart/store.js +0 -326
- package/src/client.js +0 -267
- package/src/config.js +0 -349
- package/src/dom.js +0 -287
- package/src/fragments.js +0 -237
- package/src/index.js +0 -353
- package/src/initial.js +0 -87
- package/src/lazy-iife.js +0 -116
- package/src/lazy.js +0 -39
- package/src/market-codes.js +0 -35
- package/src/markets.js +0 -334
- package/src/markup.js +0 -64
- package/src/parts/account-pages.js +0 -6
- package/src/parts/account.js +0 -6
- package/src/parts/analytics.js +0 -6
- package/src/parts/diagnose.js +0 -6
- package/src/parts/extras.js +0 -6
- package/src/parts/markets.js +0 -6
- package/src/parts/search.js +0 -6
- package/src/presets/barba.js +0 -190
- package/src/product-state.js +0 -318
- package/src/selling-plans.js +0 -154
- package/src/server.js +0 -215
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +0 -199
- package/types/astro.d.ts +0 -28
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -577
- package/types/server.d.ts +0 -182
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months. Tether pins a dated version
|
|
3
|
-
* and never targets `unstable` (it changes without notice). Used by the diagnose part and the release
|
|
3
|
+
* and never targets `unstable` (it changes without notice). Used by the config, the diagnose part and the release
|
|
4
|
+
* scripts.
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
import type { ApiVersion } from '../server/src/shared/api-versions.ts';
|
|
8
|
+
|
|
6
9
|
const QUARTERLY_VERSION = /^(\d{4})-(01|04|07|10)$/;
|
|
7
10
|
const MONTHS_PER_QUARTER = 3;
|
|
8
11
|
const QUARTERS_PER_YEAR = 4;
|
|
@@ -11,54 +14,45 @@ const MONTHS_SUPPORTED = 12;
|
|
|
11
14
|
const UPGRADE_WARNING_MONTHS = 3;
|
|
12
15
|
const AVERAGE_MONTH_MS = 30.44 * 24 * 3600 * 1000;
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
export interface ApiVersionStatus {
|
|
18
|
+
version: string;
|
|
19
|
+
/** Released and still supported. */
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** What to do about it, for people. */
|
|
22
|
+
note: string;
|
|
23
|
+
/** Months of support left, rounded; negative once support ended. */
|
|
24
|
+
monthsLeft?: number;
|
|
25
|
+
/** The version after this one. */
|
|
26
|
+
next?: string;
|
|
27
|
+
}
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
* @param {unknown} version
|
|
25
|
-
* @returns {{ year: number, month: number } | null}
|
|
26
|
-
*/
|
|
27
|
-
function parseVersion(version) {
|
|
29
|
+
function parseVersion(version: unknown): { year: number; month: number } | null {
|
|
28
30
|
const match = QUARTERLY_VERSION.exec(typeof version === 'string' ? version : '');
|
|
29
31
|
if (!match) return null;
|
|
30
32
|
return { year: Number(match[1]), month: Number(match[2]) };
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*/
|
|
37
|
-
const formatVersion = (year, month) => `${year}-${String(month).padStart(2, '0')}`;
|
|
35
|
+
/** @returns True for a dated version such as `2026-07`. */
|
|
36
|
+
export const isQuarterlyVersion = (version: unknown): version is ApiVersion => parseVersion(version) !== null;
|
|
37
|
+
|
|
38
|
+
/** @param month 1 to 12. */
|
|
39
|
+
const formatVersion = (year: number, month: number) => `${year}-${String(month).padStart(2, '0')}`;
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* `'2026-07'` → `'2026-10'`, `'2026-10'` → `'2027-01'`.
|
|
41
43
|
*
|
|
42
|
-
* @param
|
|
43
|
-
* @param {number} [steps] Quarters ahead.
|
|
44
|
-
* @returns {string}
|
|
44
|
+
* @param steps Quarters ahead.
|
|
45
45
|
* @throws {Error} When `version` isn't a quarterly version.
|
|
46
46
|
*/
|
|
47
|
-
export function nextApiVersion(version, steps = 1) {
|
|
47
|
+
export function nextApiVersion(version: string, steps = 1): string {
|
|
48
48
|
const parsed = parseVersion(version);
|
|
49
49
|
if (!parsed) throw new Error(`not a quarterly Shopify API version: ${version}`);
|
|
50
50
|
const quarter = parsed.year * QUARTERS_PER_YEAR + (parsed.month - 1) / MONTHS_PER_QUARTER + steps;
|
|
51
51
|
return formatVersion(Math.floor(quarter / QUARTERS_PER_YEAR), (quarter % QUARTERS_PER_YEAR) * MONTHS_PER_QUARTER + 1);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
* @param {string} version
|
|
58
|
-
* @param {Date} [now]
|
|
59
|
-
* @returns {ApiVersionStatus}
|
|
60
|
-
*/
|
|
61
|
-
export function apiVersionStatus(version, now = new Date()) {
|
|
54
|
+
/** The support window of `version`, relative to `now`. */
|
|
55
|
+
export function apiVersionStatus(version: string, now = new Date()): ApiVersionStatus {
|
|
62
56
|
if (version === 'unstable') {
|
|
63
57
|
return { version, ok: false, note: '"unstable" changes without notice: pin a dated version such as 2026-07' };
|
|
64
58
|
}
|
|
@@ -10,36 +10,66 @@
|
|
|
10
10
|
* PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
|
|
11
11
|
* PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
|
|
12
12
|
*
|
|
13
|
-
* Options are Tether's config (domain and token override .env)
|
|
13
|
+
* Options are Tether's config (domain and token override .env), and may be the object createStorefront() is given too:
|
|
14
|
+
* its own options (siteUrl, pageSize) are left out of the page. Functions (onError, formatMoney, hooks) can't be
|
|
14
15
|
* passed here: set them in window.TETHER_CONFIG in your layout, it is merged in. View Transitions (<ClientRouter />)
|
|
15
16
|
* work without extra code. In dev, changes to src/shop.config.js restart the dev server (other files:
|
|
16
17
|
* tether({ ..., watch: ['my/config.js'] })). Runs in Node, inside Astro's config step.
|
|
17
18
|
*/
|
|
18
|
-
import {
|
|
19
|
+
import { DEFAULTS } from './config.ts';
|
|
20
|
+
import type { TetherOptions } from './config.ts';
|
|
21
|
+
import type { StorefrontOptions } from './server.ts';
|
|
22
|
+
import { isSecretShopifyToken, STORE_ENV_NAMES } from './shopify-values.ts';
|
|
23
|
+
import { messageOf } from '../server/src/shared/error-message.ts';
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
// Astro isn't a dependency, so the parts of its integration API this file uses are described here.
|
|
26
|
+
interface AstroLogger {
|
|
27
|
+
warn: (message: string) => void;
|
|
28
|
+
}
|
|
29
|
+
type EnvStringField = (options: { context: 'client'; access: 'public'; optional: boolean }) => unknown;
|
|
30
|
+
/** The argument of Astro's `astro:config:setup` hook. */
|
|
31
|
+
interface AstroConfigSetup {
|
|
32
|
+
injectScript: (stage: 'head-inline' | 'page', content: string) => void;
|
|
33
|
+
/** `dev`, `build`, `preview` or `sync`. */
|
|
34
|
+
command: string;
|
|
35
|
+
config: { root?: URL };
|
|
36
|
+
logger: AstroLogger;
|
|
37
|
+
addWatchFile?: (path: string) => void;
|
|
38
|
+
/** Takes a partial Astro config. A method, so that Astro's own `DeepPartial<AstroConfig>` parameter fits. */
|
|
39
|
+
updateConfig?(config: { env?: { schema?: Record<string, unknown> } }): unknown;
|
|
40
|
+
}
|
|
33
41
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
/** The config options a page can be handed as JSON: all but the functions. */
|
|
43
|
+
type SerializableOptions = {
|
|
44
|
+
[
|
|
45
|
+
Key in keyof TetherOptions as Exclude<TetherOptions[Key], null | undefined> extends (...args: never[]) => unknown
|
|
46
|
+
? never
|
|
47
|
+
: Key
|
|
48
|
+
]?: TetherOptions[Key];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** `createStorefront()`'s own options that a config shared with it may hold, which the page leaves out. */
|
|
52
|
+
type ServerOnlyOptions = Pick<StorefrontOptions, 'siteUrl' | 'pageSize'>;
|
|
53
|
+
|
|
54
|
+
/** Tether's serializable config and the storefront's own options, plus the integration's own options. */
|
|
55
|
+
interface TetherAstroOptions extends SerializableOptions, ServerOnlyOptions {
|
|
56
|
+
/** Add the Tether script to every page (default true). */
|
|
57
|
+
inject?: boolean;
|
|
58
|
+
/** Files that restart the dev server when they change. */
|
|
59
|
+
watch?: string[] | null;
|
|
60
|
+
}
|
|
61
|
+
interface PageConfig {
|
|
62
|
+
domain?: unknown;
|
|
63
|
+
token?: unknown;
|
|
64
|
+
[option: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
type Env = Record<string, string | undefined>;
|
|
67
|
+
|
|
68
|
+
export type { TetherAstroOptions, AstroConfigSetup };
|
|
41
69
|
|
|
42
70
|
const DEFAULT_WATCH = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'];
|
|
71
|
+
/** The keys of `ServerOnlyOptions`: the browser has no use for them. */
|
|
72
|
+
const SERVER_OPTIONS = new Set(['siteUrl', 'pageSize']);
|
|
43
73
|
/**
|
|
44
74
|
* Astro is an optional peer dependency. A variable specifier keeps both tsc and the bundler from resolving it.
|
|
45
75
|
*/
|
|
@@ -48,12 +78,8 @@ const ENV_LINE = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/;
|
|
|
48
78
|
/** A quoted value; a `#` comment may follow the closing quote. */
|
|
49
79
|
const QUOTED_VALUE = /^(["'])(.*?)\1/;
|
|
50
80
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
* @param {readonly string[]} [argv]
|
|
54
|
-
* @returns {string} The mode Astro runs in: `--mode`, else development for dev and production for everything else.
|
|
55
|
-
*/
|
|
56
|
-
function modeOf(command, argv = process.argv) {
|
|
81
|
+
/** @returns The mode Astro runs in: `--mode`, else development for dev and production for everything else. */
|
|
82
|
+
function modeOf(command: string, argv: readonly string[] = process.argv): string {
|
|
57
83
|
const defaultMode = command === 'dev' ? 'development' : 'production';
|
|
58
84
|
const index = argv.findIndex((arg) => arg === '--mode' || arg.startsWith('--mode='));
|
|
59
85
|
if (index < 0) return defaultMode;
|
|
@@ -62,17 +88,11 @@ function modeOf(command, argv = process.argv) {
|
|
|
62
88
|
return mode ?? defaultMode;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
* @returns {boolean} True for Node's "no such file" error.
|
|
68
|
-
*/
|
|
69
|
-
const isMissingFile = (error) => error instanceof Error && Reflect.get(error, 'code') === 'ENOENT';
|
|
91
|
+
/** @returns True for Node's "no such file" error. */
|
|
92
|
+
const isMissingFile = (error: unknown): boolean => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
70
93
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
* @returns {Promise<string | null>} The file's text, `null` when it doesn't exist: every .env file is optional.
|
|
74
|
-
*/
|
|
75
|
-
async function readOptionalFile(path) {
|
|
94
|
+
/** @returns The file's text, `null` when it doesn't exist: every .env file is optional. */
|
|
95
|
+
async function readOptionalFile(path: string): Promise<string | null> {
|
|
76
96
|
const { readFile } = await import('node:fs/promises');
|
|
77
97
|
try {
|
|
78
98
|
return await readFile(path, 'utf8');
|
|
@@ -83,10 +103,10 @@ async function readOptionalFile(path) {
|
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
/**
|
|
86
|
-
* @param
|
|
87
|
-
* @param
|
|
106
|
+
* @param text A .env file.
|
|
107
|
+
* @param into Receives each variable; a later line wins.
|
|
88
108
|
*/
|
|
89
|
-
function parseEnvFile(text, into) {
|
|
109
|
+
function parseEnvFile(text: string, into: Env) {
|
|
90
110
|
for (const line of text.split(/\r?\n/)) {
|
|
91
111
|
const match = ENV_LINE.exec(line);
|
|
92
112
|
if (!match || line.trim().startsWith('#')) continue;
|
|
@@ -97,15 +117,13 @@ function parseEnvFile(text, into) {
|
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
/**
|
|
100
|
-
* @param
|
|
101
|
-
* @
|
|
102
|
-
* @returns {Promise<Env>} The .env files the way Vite reads them (later files win), with `process.env` on top.
|
|
120
|
+
* @param root The project folder.
|
|
121
|
+
* @returns The .env files the way Vite reads them (later files win), with `process.env` on top.
|
|
103
122
|
*/
|
|
104
|
-
async function readEnv(command, root) {
|
|
123
|
+
async function readEnv(command: string, root: string): Promise<Env> {
|
|
105
124
|
const path = await import('node:path');
|
|
106
125
|
const mode = modeOf(command);
|
|
107
|
-
|
|
108
|
-
const env = {};
|
|
126
|
+
const env: Env = {};
|
|
109
127
|
for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
|
|
110
128
|
const text = await readOptionalFile(path.join(root, name));
|
|
111
129
|
if (text !== null) parseEnvFile(text, env);
|
|
@@ -117,13 +135,11 @@ async function readEnv(command, root) {
|
|
|
117
135
|
* Fills the store's domain and token from .env when the options don't set them, and refuses a secret token: whatever
|
|
118
136
|
* is read here is written into every page.
|
|
119
137
|
*
|
|
120
|
-
* @param
|
|
121
|
-
* @param {Env} env
|
|
122
|
-
* @param {AstroLogger} logger
|
|
138
|
+
* @param config Changed in place.
|
|
123
139
|
* @throws {Error} When the token is a secret Shopify token.
|
|
124
140
|
*/
|
|
125
|
-
function applyStoreEnv(config, env, logger) {
|
|
126
|
-
for (const key of
|
|
141
|
+
function applyStoreEnv(config: PageConfig, env: Env, logger: AstroLogger) {
|
|
142
|
+
for (const key of ['domain', 'token'] as const) {
|
|
127
143
|
if (config[key]) continue;
|
|
128
144
|
const name = STORE_ENV_NAMES[key].find((candidate) => env[candidate]);
|
|
129
145
|
if (!name) continue;
|
|
@@ -143,12 +159,8 @@ function applyStoreEnv(config, env, logger) {
|
|
|
143
159
|
/**
|
|
144
160
|
* The options usually come from a file the Astro config imports (src/shop.config.js): the dev server restarts when it
|
|
145
161
|
* changes, so the browser gets the new config too.
|
|
146
|
-
*
|
|
147
|
-
* @param {string} root
|
|
148
|
-
* @param {readonly string[]} files
|
|
149
|
-
* @param {(path: string) => void} addWatchFile
|
|
150
162
|
*/
|
|
151
|
-
async function watchConfigFiles(root, files, addWatchFile) {
|
|
163
|
+
async function watchConfigFiles(root: string, files: readonly string[], addWatchFile: (path: string) => void) {
|
|
152
164
|
const { existsSync } = await import('node:fs');
|
|
153
165
|
const path = await import('node:path');
|
|
154
166
|
for (const file of files) {
|
|
@@ -157,50 +169,46 @@ async function watchConfigFiles(root, files, addWatchFile) {
|
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
171
|
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
* @returns {Promise<unknown>} The module, `null` when the package isn't installed.
|
|
163
|
-
*/
|
|
164
|
-
async function importIfInstalled(specifier) {
|
|
172
|
+
/** @returns The module, `null` when the package isn't installed. */
|
|
173
|
+
async function importIfInstalled(specifier: string): Promise<unknown> {
|
|
165
174
|
try {
|
|
166
|
-
|
|
167
|
-
const module = await import(specifier);
|
|
175
|
+
const module: unknown = await import(specifier);
|
|
168
176
|
return module;
|
|
169
177
|
} catch (error) {
|
|
170
|
-
if (error instanceof Error &&
|
|
178
|
+
if (error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND') return null;
|
|
171
179
|
throw error;
|
|
172
180
|
}
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
/**
|
|
184
|
+
* @param envField `astro/config`'s `envField`, which Astro 4 doesn't have.
|
|
185
|
+
* @returns True when it has the `string` field Astro 5 documents.
|
|
186
|
+
*/
|
|
187
|
+
const hasStringField = (envField: unknown): envField is { string: EnvStringField } =>
|
|
188
|
+
typeof envField === 'object' && envField !== null && 'string' in envField && typeof envField.string === 'function';
|
|
189
|
+
|
|
175
190
|
/**
|
|
176
191
|
* Declares the store variables in `astro:env` (Astro 5+), so Astro checks them. Astro 4 has no `envField`.
|
|
177
|
-
*
|
|
178
|
-
* @param {NonNullable<AstroConfigSetup['updateConfig']>} updateConfig
|
|
179
|
-
* @param {AstroLogger} logger
|
|
180
192
|
*/
|
|
181
|
-
async function declareEnvSchema(
|
|
193
|
+
async function declareEnvSchema(setup: AstroConfigSetup) {
|
|
194
|
+
if (!setup.updateConfig) return;
|
|
182
195
|
const astroConfig = await importIfInstalled(ASTRO_CONFIG_MODULE);
|
|
183
|
-
/** @type {unknown} */
|
|
184
196
|
const envField =
|
|
185
|
-
typeof astroConfig === 'object' && astroConfig !== null
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
// Checked above: Astro's `envField.string`.
|
|
189
|
-
const fields = /** @type {{ string: EnvStringField }} */ (envField);
|
|
190
|
-
const field = () => fields.string({ context: 'client', access: 'public', optional: true });
|
|
197
|
+
typeof astroConfig === 'object' && astroConfig !== null && 'envField' in astroConfig ? astroConfig.envField : null;
|
|
198
|
+
if (!hasStringField(envField)) return;
|
|
199
|
+
const field = () => envField.string({ context: 'client', access: 'public', optional: true });
|
|
191
200
|
try {
|
|
192
|
-
updateConfig({
|
|
201
|
+
setup.updateConfig({
|
|
202
|
+
env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } },
|
|
203
|
+
});
|
|
193
204
|
} catch (error) {
|
|
194
205
|
// Astro versions with `envField` but without a stable `env` option refuse it; the variables still work unchecked.
|
|
195
|
-
logger.warn(`.env variables not declared in astro:env: ${
|
|
206
|
+
setup.logger.warn(`.env variables not declared in astro:env: ${messageOf(error)}`);
|
|
196
207
|
}
|
|
197
208
|
}
|
|
198
209
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
* @param {AstroLogger} logger
|
|
202
|
-
*/
|
|
203
|
-
function dropFunctions(config, logger) {
|
|
210
|
+
/** @param config Changed in place: functions can't be written into a page. */
|
|
211
|
+
function dropFunctions(config: PageConfig, logger: AstroLogger) {
|
|
204
212
|
const functions = Object.keys(config).filter((key) => typeof config[key] === 'function');
|
|
205
213
|
for (const key of functions) Reflect.deleteProperty(config, key);
|
|
206
214
|
if (functions.length) {
|
|
@@ -210,11 +218,16 @@ function dropFunctions(config, logger) {
|
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
220
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
221
|
+
/** @param config Changed in place: only browser options are written into the page. */
|
|
222
|
+
function keepBrowserOptions(config: PageConfig, logger: AstroLogger) {
|
|
223
|
+
for (const key of Object.keys(config)) {
|
|
224
|
+
if (key in DEFAULTS) continue;
|
|
225
|
+
Reflect.deleteProperty(config, key);
|
|
226
|
+
if (!SERVER_OPTIONS.has(key)) logger.warn(`${key} is not a Tether option: left out of the page.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function injectTether(injectScript: AstroConfigSetup['injectScript'], config: PageConfig) {
|
|
218
231
|
// Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
|
|
219
232
|
injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
|
|
220
233
|
injectScript(
|
|
@@ -229,31 +242,24 @@ function injectTether(injectScript, config) {
|
|
|
229
242
|
);
|
|
230
243
|
}
|
|
231
244
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
export default function tether(options = {}) {
|
|
245
|
+
export default function tether(options: TetherAstroOptions = {}): {
|
|
246
|
+
name: string;
|
|
247
|
+
hooks: { 'astro:config:setup': (setup: AstroConfigSetup) => Promise<void> };
|
|
248
|
+
} {
|
|
237
249
|
const { inject = true, watch = DEFAULT_WATCH, ...pageOptions } = options;
|
|
238
250
|
return {
|
|
239
251
|
name: '@occluze/tether',
|
|
240
252
|
hooks: {
|
|
241
|
-
'astro:config:setup': async ({
|
|
242
|
-
injectScript,
|
|
243
|
-
command,
|
|
244
|
-
config: astroConfig,
|
|
245
|
-
logger,
|
|
246
|
-
addWatchFile,
|
|
247
|
-
updateConfig,
|
|
248
|
-
}) => {
|
|
253
|
+
'astro:config:setup': async (setup) => {
|
|
254
|
+
const { injectScript, command, config: astroConfig, logger, addWatchFile } = setup;
|
|
249
255
|
const { fileURLToPath } = await import('node:url');
|
|
250
256
|
const root = astroConfig.root ? fileURLToPath(astroConfig.root) : process.cwd();
|
|
251
257
|
if (command === 'dev' && addWatchFile) await watchConfigFiles(root, watch ?? [], addWatchFile);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const config = { ...pageOptions };
|
|
258
|
+
await declareEnvSchema(setup);
|
|
259
|
+
const config: PageConfig = { ...pageOptions };
|
|
255
260
|
applyStoreEnv(config, await readEnv(command, root), logger);
|
|
256
261
|
dropFunctions(config, logger);
|
|
262
|
+
keepBrowserOptions(config, logger);
|
|
257
263
|
if (!config.domain || !config.token) {
|
|
258
264
|
logger.warn(
|
|
259
265
|
'No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structure of Tether's cart fragment, checked on a Storefront answer before it is used as a `ShopifyCart`. The
|
|
3
|
+
* cart's render, its totals and the cart API walk its lines and prices without checking each step. Text fields are
|
|
4
|
+
* written as text, so they need no check.
|
|
5
|
+
*/
|
|
6
|
+
import { MONEY } from '../product-shape.ts';
|
|
7
|
+
import type { Shape } from '../client.ts';
|
|
8
|
+
|
|
9
|
+
const LINE = {
|
|
10
|
+
id: 'string',
|
|
11
|
+
quantity: 'number',
|
|
12
|
+
attributes: ['object'],
|
|
13
|
+
cost: { totalAmount: MONEY, subtotalAmount: MONEY, amountPerQuantity: MONEY },
|
|
14
|
+
merchandise: { id: 'string', selectedOptions: ['object'], product: 'object' },
|
|
15
|
+
} as const satisfies Shape;
|
|
16
|
+
|
|
17
|
+
export const CART = {
|
|
18
|
+
id: 'string',
|
|
19
|
+
checkoutUrl: 'string',
|
|
20
|
+
totalQuantity: 'number',
|
|
21
|
+
attributes: ['object'],
|
|
22
|
+
discountCodes: ['object'],
|
|
23
|
+
cost: { totalAmount: MONEY, subtotalAmount: MONEY },
|
|
24
|
+
lines: { nodes: [LINE] },
|
|
25
|
+
} as const satisfies Shape;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending the visitor to Shopify's checkout. The URL comes from Shopify at click time, because the cart's checkout URL
|
|
3
|
+
* changes with its market and customer; the account part resolves a logged-in customer's own (`setCheckoutResolver`).
|
|
4
|
+
*/
|
|
5
|
+
import { cfg, reportError } from '../config.ts';
|
|
6
|
+
import { TetherError } from '../client.ts';
|
|
7
|
+
import { isHttpsUrl } from '../shopify-values.ts';
|
|
8
|
+
import { emit } from '../dom.ts';
|
|
9
|
+
import { fetchCheckoutUrl } from './mutations.ts';
|
|
10
|
+
import { currentCart, savedCartId } from './store.ts';
|
|
11
|
+
import type { CheckoutTarget } from '../config.ts';
|
|
12
|
+
|
|
13
|
+
type CheckoutUrlAnswer = { kind: 'url'; url: string | null } | { kind: 'failed'; error: unknown };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The logged-in customer's checkout URL for the cart; `null` when nobody is logged in, so the cart's own checkout URL
|
|
17
|
+
* is used.
|
|
18
|
+
*/
|
|
19
|
+
type CheckoutResolver = () => Promise<string | null>;
|
|
20
|
+
|
|
21
|
+
let resolveAccountCheckout: CheckoutResolver | null = null;
|
|
22
|
+
|
|
23
|
+
/** Sets the account part's checkout URL, or clears it with `null`. A URL it answers opens checkout logged in. */
|
|
24
|
+
export function setCheckoutResolver(resolver: CheckoutResolver | null): void {
|
|
25
|
+
resolveAccountCheckout = resolver;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns Shopify's answer as a value, so the request can run alongside the account's and its failure is read only
|
|
30
|
+
* when its URL is the one used.
|
|
31
|
+
*/
|
|
32
|
+
const askCheckoutUrl = (cartId: string): Promise<CheckoutUrlAnswer> =>
|
|
33
|
+
fetchCheckoutUrl(cartId).then(
|
|
34
|
+
(url) => ({ kind: 'url', url }),
|
|
35
|
+
(error: unknown) => ({ kind: 'failed', error }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns The logged-in customer's checkout URL. When the account can't give one, the failure is reported and the
|
|
40
|
+
* cart's own checkout URL is used, which still checks out (without the login).
|
|
41
|
+
*/
|
|
42
|
+
async function accountCheckoutUrl(): Promise<string | null> {
|
|
43
|
+
if (!resolveAccountCheckout) return null;
|
|
44
|
+
try {
|
|
45
|
+
const url = await resolveAccountCheckout();
|
|
46
|
+
// Shopify's checkout shows the customer as logged in only with this parameter.
|
|
47
|
+
return url && `${url}${url.includes('?') ? '&' : '?'}logged_in=true`;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
reportError(error, 'account checkout');
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @throws {TetherError} `ERROR` without a checkout URL (the cart is empty or Shopify no longer has it). */
|
|
55
|
+
async function checkoutUrl(cartId: string): Promise<string> {
|
|
56
|
+
const cartAnswer = askCheckoutUrl(cartId);
|
|
57
|
+
const accountUrl = await accountCheckoutUrl();
|
|
58
|
+
if (accountUrl) return accountUrl;
|
|
59
|
+
const answer = await cartAnswer;
|
|
60
|
+
if (answer.kind === 'failed') throw answer.error;
|
|
61
|
+
if (!answer.url) throw new TetherError('No checkoutUrl (cart empty or expired)');
|
|
62
|
+
return answer.url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A new tab's `opener` gives the checkout page a handle on this one; a browser that doesn't let a script clear it
|
|
67
|
+
* leaves the tab as it opened.
|
|
68
|
+
*/
|
|
69
|
+
function detachOpenerIfAllowed(tab: Window) {
|
|
70
|
+
try {
|
|
71
|
+
tab.opener = null;
|
|
72
|
+
} catch {
|
|
73
|
+
// The browser keeps `opener` read-only: see the function's comment.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Goes to Shopify's checkout. With `_blank` the tab opens inside the click (Safari blocks `window.open` after an
|
|
79
|
+
* await) and gets its URL once there is one.
|
|
80
|
+
*
|
|
81
|
+
* @returns The checkout URL, `null` with nothing to check out or after a reported failure.
|
|
82
|
+
*/
|
|
83
|
+
export async function checkout(target: CheckoutTarget = cfg.checkoutTarget): Promise<string | null> {
|
|
84
|
+
const cartId = savedCartId();
|
|
85
|
+
const cart = currentCart();
|
|
86
|
+
if (!cartId || (cart && !cart.totalQuantity)) return null;
|
|
87
|
+
const tab = target === '_blank' ? window.open('', '_blank') : null;
|
|
88
|
+
if (tab) detachOpenerIfAllowed(tab);
|
|
89
|
+
try {
|
|
90
|
+
const url = await checkoutUrl(cartId);
|
|
91
|
+
if (!isHttpsUrl(url)) throw new TetherError('The checkout URL is not https: not opened');
|
|
92
|
+
emit('checkout', { url, cart: currentCart() });
|
|
93
|
+
if (tab && !tab.closed) tab.location.href = url;
|
|
94
|
+
else window.location.href = url;
|
|
95
|
+
return url;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
tab?.close();
|
|
98
|
+
reportError(error, 'checkout failed');
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* into the open cart, Tab stays inside it and focus returns on close (`cartFocus`). A page without a drawer (a cart
|
|
4
4
|
* built in React, Svelte or Vue) still has an open state, for `Tether.cart.subscribe`.
|
|
5
5
|
*/
|
|
6
|
-
import { cfg } from '../config.
|
|
7
|
-
import { sel, get, qsa, emit } from '../dom.
|
|
6
|
+
import { cfg } from '../config.ts';
|
|
7
|
+
import { sel, get, qsa, emit } from '../dom.ts';
|
|
8
8
|
|
|
9
9
|
const FOCUSABLE =
|
|
10
10
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
@@ -15,30 +15,22 @@ const FOCUS_AFTER_OPEN_MS = 60;
|
|
|
15
15
|
/** The open state of a page without a drawer element. */
|
|
16
16
|
let openWithoutDrawer = false;
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
let returnFocus = null;
|
|
18
|
+
/** Where focus was before the cart opened. */
|
|
19
|
+
let returnFocus: HTMLElement | null = null;
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
* @param {Element | null} el
|
|
23
|
-
* @returns {el is HTMLElement}
|
|
24
|
-
*/
|
|
25
|
-
const isFocusable = (el) => el !== null && 'focus' in el;
|
|
21
|
+
const isFocusable = (el: Element | null): el is HTMLElement => el !== null && 'focus' in el;
|
|
26
22
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
* @returns {HTMLElement[]} The visible focusable elements inside `drawer`, in document order.
|
|
30
|
-
*/
|
|
31
|
-
const focusables = (drawer) =>
|
|
23
|
+
/** @returns The visible focusable elements inside `drawer`, in document order. */
|
|
24
|
+
const focusables = (drawer: ParentNode): HTMLElement[] =>
|
|
32
25
|
qsa(drawer, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
33
26
|
|
|
34
|
-
/**
|
|
35
|
-
export const drawers = () => qsa(document, sel('cart-drawer'));
|
|
27
|
+
/** Every cart drawer on the page. */
|
|
28
|
+
export const drawers = (): HTMLElement[] => qsa(document, sel('cart-drawer'));
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
const isOpenDrawer = (drawer) => drawer.classList.contains(cfg.cartDrawerOpenClass);
|
|
30
|
+
const isOpenDrawer = (drawer: HTMLElement) => drawer.classList.contains(cfg.cartDrawerOpenClass);
|
|
39
31
|
|
|
40
|
-
/**
|
|
41
|
-
export function isOpen() {
|
|
32
|
+
/** True while a drawer, or the drawer-less cart, is open. */
|
|
33
|
+
export function isOpen(): boolean {
|
|
42
34
|
const all = drawers();
|
|
43
35
|
return all.length ? all.some(isOpenDrawer) : openWithoutDrawer;
|
|
44
36
|
}
|
|
@@ -46,10 +38,8 @@ export function isOpen() {
|
|
|
46
38
|
/**
|
|
47
39
|
* `aria-expanded` on the open buttons, and dialog semantics on the drawers unless their markup has its own. A closed
|
|
48
40
|
* drawer is hidden from screen readers and, with `cartFocus`, out of the Tab order (inert), like a closed dialog.
|
|
49
|
-
*
|
|
50
|
-
* @param {boolean} open
|
|
51
41
|
*/
|
|
52
|
-
export function syncDrawerAria(open) {
|
|
42
|
+
export function syncDrawerAria(open: boolean): void {
|
|
53
43
|
qsa(document, sel('cart-open')).forEach((button) => {
|
|
54
44
|
button.setAttribute('aria-expanded', String(open));
|
|
55
45
|
});
|
|
@@ -65,15 +55,14 @@ export function syncDrawerAria(open) {
|
|
|
65
55
|
});
|
|
66
56
|
}
|
|
67
57
|
|
|
68
|
-
|
|
69
|
-
function focusInto(drawer) {
|
|
58
|
+
function focusInto(drawer: HTMLElement) {
|
|
70
59
|
const target = focusables(drawer)[0] ?? drawer;
|
|
71
60
|
if (target === drawer && !drawer.hasAttribute('tabindex')) drawer.tabIndex = -1;
|
|
72
61
|
target.focus({ preventScroll: true });
|
|
73
62
|
}
|
|
74
63
|
|
|
75
64
|
/** Opens every drawer except `tether-cart-drawer="manual"` ones, which the site opens itself. */
|
|
76
|
-
export function openCart() {
|
|
65
|
+
export function openCart(): void {
|
|
77
66
|
if (isOpen()) return;
|
|
78
67
|
openWithoutDrawer = true;
|
|
79
68
|
const opened = drawers().filter((drawer) => get(drawer, 'cart-drawer') !== 'manual');
|
|
@@ -94,7 +83,7 @@ export function openCart() {
|
|
|
94
83
|
emit('cart-toggled', { open: true });
|
|
95
84
|
}
|
|
96
85
|
|
|
97
|
-
export function closeCart() {
|
|
86
|
+
export function closeCart(): void {
|
|
98
87
|
if (!isOpen()) return;
|
|
99
88
|
openWithoutDrawer = false;
|
|
100
89
|
const all = drawers();
|
|
@@ -111,7 +100,7 @@ export function closeCart() {
|
|
|
111
100
|
emit('cart-toggled', { open: false });
|
|
112
101
|
}
|
|
113
102
|
|
|
114
|
-
export function toggleCart() {
|
|
103
|
+
export function toggleCart(): void {
|
|
115
104
|
if (isOpen()) closeCart();
|
|
116
105
|
else openCart();
|
|
117
106
|
}
|
|
@@ -119,10 +108,8 @@ export function toggleCart() {
|
|
|
119
108
|
/**
|
|
120
109
|
* Keeps Tab inside the open drawer (`cartFocus`): from the last focusable element it wraps to the first, with Shift
|
|
121
110
|
* from the first to the last, and from outside the drawer it goes in. For a document `keydown` listener.
|
|
122
|
-
*
|
|
123
|
-
* @param {KeyboardEvent} event
|
|
124
111
|
*/
|
|
125
|
-
export function trapFocus(event) {
|
|
112
|
+
export function trapFocus(event: KeyboardEvent): void {
|
|
126
113
|
if (event.key !== 'Tab' || !cfg.cartFocus || !isOpen()) return;
|
|
127
114
|
const drawer = drawers().find(isOpenDrawer);
|
|
128
115
|
if (!drawer) return;
|