@brandlab-digital/site-next 3.8.0 → 3.9.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/dist/instrumentation-client.d.ts +23 -4
- package/dist/instrumentation-client.d.ts.map +1 -1
- package/dist/instrumentation-client.js +65 -3
- package/dist/instrumentation-client.js.map +1 -1
- package/dist/navigation/constants.d.ts +25 -0
- package/dist/navigation/constants.d.ts.map +1 -0
- package/dist/navigation/constants.js +30 -0
- package/dist/navigation/constants.js.map +1 -0
- package/dist/navigation/index.d.ts +11 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +11 -0
- package/dist/navigation/index.js.map +1 -0
- package/dist/navigation/use-back-to-top.d.ts +5 -0
- package/dist/navigation/use-back-to-top.d.ts.map +1 -0
- package/dist/navigation/use-back-to-top.js +32 -0
- package/dist/navigation/use-back-to-top.js.map +1 -0
- package/dist/navigation/use-header-metrics.d.ts +17 -0
- package/dist/navigation/use-header-metrics.d.ts.map +1 -0
- package/dist/navigation/use-header-metrics.js +52 -0
- package/dist/navigation/use-header-metrics.js.map +1 -0
- package/dist/navigation/use-navigation-menu.d.ts +39 -0
- package/dist/navigation/use-navigation-menu.d.ts.map +1 -0
- package/dist/navigation/use-navigation-menu.js +206 -0
- package/dist/navigation/use-navigation-menu.js.map +1 -0
- package/dist/navigation/use-sticky-call-bar.d.ts +15 -0
- package/dist/navigation/use-sticky-call-bar.d.ts.map +1 -0
- package/dist/navigation/use-sticky-call-bar.js +110 -0
- package/dist/navigation/use-sticky-call-bar.js.map +1 -0
- package/dist/ui-kit/brandlab-skin.d.ts +10 -0
- package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
- package/dist/ui-kit/brandlab-skin.js.map +1 -1
- package/dist/ui-kit/shell/back-to-top-client.d.ts +9 -0
- package/dist/ui-kit/shell/back-to-top-client.d.ts.map +1 -0
- package/dist/ui-kit/shell/back-to-top-client.js +8 -0
- package/dist/ui-kit/shell/back-to-top-client.js.map +1 -0
- package/dist/ui-kit/shell/back-to-top.d.ts +13 -0
- package/dist/ui-kit/shell/back-to-top.d.ts.map +1 -0
- package/dist/ui-kit/shell/back-to-top.js +14 -0
- package/dist/ui-kit/shell/back-to-top.js.map +1 -0
- package/dist/ui-kit/shell/call-bar-client.d.ts +7 -0
- package/dist/ui-kit/shell/call-bar-client.d.ts.map +1 -0
- package/dist/ui-kit/shell/call-bar-client.js +13 -0
- package/dist/ui-kit/shell/call-bar-client.js.map +1 -0
- package/dist/ui-kit/shell/current-link.d.ts +15 -0
- package/dist/ui-kit/shell/current-link.d.ts.map +1 -0
- package/dist/ui-kit/shell/current-link.js +34 -0
- package/dist/ui-kit/shell/current-link.js.map +1 -0
- package/dist/ui-kit/shell/drawer-client.d.ts +22 -0
- package/dist/ui-kit/shell/drawer-client.d.ts.map +1 -0
- package/dist/ui-kit/shell/drawer-client.js +70 -0
- package/dist/ui-kit/shell/drawer-client.js.map +1 -0
- package/dist/ui-kit/shell/header-client.d.ts +44 -0
- package/dist/ui-kit/shell/header-client.d.ts.map +1 -0
- package/dist/ui-kit/shell/header-client.js +59 -0
- package/dist/ui-kit/shell/header-client.js.map +1 -0
- package/dist/ui-kit/shell/index.d.ts +6 -0
- package/dist/ui-kit/shell/index.d.ts.map +1 -0
- package/dist/ui-kit/shell/index.js +6 -0
- package/dist/ui-kit/shell/index.js.map +1 -0
- package/dist/ui-kit/shell/mobile-call-bar.d.ts +16 -0
- package/dist/ui-kit/shell/mobile-call-bar.d.ts.map +1 -0
- package/dist/ui-kit/shell/mobile-call-bar.js +15 -0
- package/dist/ui-kit/shell/mobile-call-bar.js.map +1 -0
- package/dist/ui-kit/shell/mobile-drawer.d.ts +37 -0
- package/dist/ui-kit/shell/mobile-drawer.d.ts.map +1 -0
- package/dist/ui-kit/shell/mobile-drawer.js +25 -0
- package/dist/ui-kit/shell/mobile-drawer.js.map +1 -0
- package/dist/ui-kit/shell/shell-context.d.ts +3 -0
- package/dist/ui-kit/shell/shell-context.d.ts.map +1 -0
- package/dist/ui-kit/shell/shell-context.js +5 -0
- package/dist/ui-kit/shell/shell-context.js.map +1 -0
- package/dist/ui-kit/shell/site-header.d.ts +52 -0
- package/dist/ui-kit/shell/site-header.d.ts.map +1 -0
- package/dist/ui-kit/shell/site-header.js +36 -0
- package/dist/ui-kit/shell/site-header.js.map +1 -0
- package/dist/ui-kit/shell/social-links.d.ts +8 -0
- package/dist/ui-kit/shell/social-links.d.ts.map +1 -0
- package/dist/ui-kit/shell/social-links.js +9 -0
- package/dist/ui-kit/shell/social-links.js.map +1 -0
- package/dist/ui-kit/shell/utility-bar.d.ts +30 -0
- package/dist/ui-kit/shell/utility-bar.d.ts.map +1 -0
- package/dist/ui-kit/shell/utility-bar.js +15 -0
- package/dist/ui-kit/shell/utility-bar.js.map +1 -0
- package/dist/ui-kit/structure.css +594 -0
- package/package.json +10 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ContactAttributionSummary } from '@brandlab-digital/site-core/contact';
|
|
2
|
-
import { type BrandlabAnalytics, type PhoneCallPlacement } from './analytics.js';
|
|
2
|
+
import { type BrandlabAnalytics, type LeadCtaPlacement, type PhoneCallPlacement } from './analytics.js';
|
|
3
3
|
export type PhoneCallLocation = PhoneCallPlacement;
|
|
4
4
|
export interface LastPhoneCallEvent {
|
|
5
5
|
key: string;
|
|
@@ -35,15 +35,34 @@ export declare function installPhoneClickListener(options: {
|
|
|
35
35
|
win?: Window;
|
|
36
36
|
dedupeWindowMs?: number;
|
|
37
37
|
}): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* The single delegated listener for lead calls to action: a click on an
|
|
40
|
+
* element carrying `data-bl-cta` (the ui-kit `QuoteButton`) sends one
|
|
41
|
+
* `lead_cta_click` with its cta id and its `data-bl-cta-placement`. Elements
|
|
42
|
+
* without an id or with a placement outside `LEAD_CTA_PLACEMENTS` send nothing.
|
|
43
|
+
* Rapid repeats of the same call to action on the same page are de-duplicated
|
|
44
|
+
* with the phone listener's window.
|
|
45
|
+
*/
|
|
46
|
+
export declare function installLeadCtaClickListener(options: {
|
|
47
|
+
trackLeadCtaClick: (context: {
|
|
48
|
+
pagePath: string;
|
|
49
|
+
ctaId: string;
|
|
50
|
+
placement: LeadCtaPlacement;
|
|
51
|
+
}) => void;
|
|
52
|
+
win?: Window;
|
|
53
|
+
dedupeWindowMs?: number;
|
|
54
|
+
}): () => void;
|
|
38
55
|
/**
|
|
39
56
|
* The whole of `src/instrumentation-client.ts`:
|
|
40
57
|
* `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing
|
|
41
58
|
* path and visit source for contact attribution, and installs the single
|
|
42
59
|
* telephone-link listener that sends one `phone_link_click` per tap (rapid
|
|
43
|
-
* repeats of the same link are de-duplicated).
|
|
44
|
-
*
|
|
60
|
+
* repeats of the same link are de-duplicated). With the binding's
|
|
61
|
+
* `trackLeadCtaClick`, it also installs the `data-bl-cta` listener, which sends
|
|
62
|
+
* `lead_cta_click` only while lead diagnostics are on. Installing twice in one
|
|
63
|
+
* window is a no-op; the returned function removes the listeners.
|
|
45
64
|
*/
|
|
46
|
-
export declare function installBrandlabInstrumentation(analytics: Pick<BrandlabAnalytics, 'trackPhoneLinkClick'
|
|
65
|
+
export declare function installBrandlabInstrumentation(analytics: Pick<BrandlabAnalytics, 'trackPhoneLinkClick'> & Partial<Pick<BrandlabAnalytics, 'trackLeadCtaClick'>>, options?: {
|
|
47
66
|
win?: Window;
|
|
48
67
|
dedupeWindowMs?: number;
|
|
49
68
|
}): () => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instrumentation-client.d.ts","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,yBAAyB,EAC/B,MAAM,qCAAqC,CAAA;AAC5C,OAAO,
|
|
1
|
+
{"version":3,"file":"instrumentation-client.d.ts","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,yBAAyB,EAC/B,MAAM,qCAAqC,CAAA;AAC5C,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACxB,MAAM,gBAAgB,CAAA;AAMvB,MAAM,MAAM,iBAAiB,GAAG,kBAAkB,CAAA;AAElD,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,EAAE,EAAE,MAAM,CAAA;CACX;AAED,eAAO,MAAM,8BAA8B,6BAA6B,CAAA;AACxE,eAAO,MAAM,mCAAmC,OAAO,CAAA;AAEvD,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,UAe9E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,UAEtE;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,kBAAkB,CAAA;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,WAOA;AAED,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAe,QAgCzD;AAED,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAe,sBAOzD;AAED,wBAAgB,4BAA4B,CAAC,GAAG,GAAE,MAAe,GAAG,yBAAyB,CAkB5F;AASD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,iBAAiB,GAAG,iBAAiB,CAc/E;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,mBAAmB,EAAE,CAAC,OAAO,EAAE;QAC7B,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,iBAAiB,CAAA;QAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAA;KAC5B,KAAK,IAAI,CAAA;IACV,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,cA4CA;AASD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE;IACnD,iBAAiB,EAAE,CAAC,OAAO,EAAE;QAC3B,QAAQ,EAAE,MAAM,CAAA;QAChB,KAAK,EAAE,MAAM,CAAA;QACb,SAAS,EAAE,gBAAgB,CAAA;KAC5B,KAAK,IAAI,CAAA;IACV,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,cA8BA;AAID;;;;;;;;;GASG;AACH,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,IAAI,CAAC,iBAAiB,EAAE,qBAAqB,CAAC,GACvD,OAAO,CAAC,IAAI,CAAC,iBAAiB,EAAE,mBAAmB,CAAC,CAAC,EACvD,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GACtD,MAAM,IAAI,CAyCZ"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isContactVisitSource, } from '@brandlab-digital/site-core/contact';
|
|
2
|
-
import { PHONE_CALL_PLACEMENTS, } from './analytics.js';
|
|
2
|
+
import { LEAD_CTA_PLACEMENTS, PHONE_CALL_PLACEMENTS, } from './analytics.js';
|
|
3
3
|
import { classifyContactAttribution, normalizeContactAttributionSummary, } from './contact/attribution.js';
|
|
4
4
|
export const INITIAL_LANDING_STORAGE_PREFIX = 'brandlab.initialLanding.';
|
|
5
5
|
export const DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS = 1200;
|
|
@@ -152,14 +152,61 @@ export function installPhoneClickListener(options) {
|
|
|
152
152
|
win.document.removeEventListener('click', handlePhoneClick, true);
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
|
+
function leadCtaPlacementValue(value) {
|
|
156
|
+
const normalized = value?.trim().toLowerCase();
|
|
157
|
+
return LEAD_CTA_PLACEMENTS.includes(normalized)
|
|
158
|
+
? normalized
|
|
159
|
+
: undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The single delegated listener for lead calls to action: a click on an
|
|
163
|
+
* element carrying `data-bl-cta` (the ui-kit `QuoteButton`) sends one
|
|
164
|
+
* `lead_cta_click` with its cta id and its `data-bl-cta-placement`. Elements
|
|
165
|
+
* without an id or with a placement outside `LEAD_CTA_PLACEMENTS` send nothing.
|
|
166
|
+
* Rapid repeats of the same call to action on the same page are de-duplicated
|
|
167
|
+
* with the phone listener's window.
|
|
168
|
+
*/
|
|
169
|
+
export function installLeadCtaClickListener(options) {
|
|
170
|
+
const win = options.win ?? window;
|
|
171
|
+
const trackLeadCtaClick = options.trackLeadCtaClick;
|
|
172
|
+
if (!trackLeadCtaClick) {
|
|
173
|
+
throw new Error('installLeadCtaClickListener requires trackLeadCtaClick.');
|
|
174
|
+
}
|
|
175
|
+
let lastEvent;
|
|
176
|
+
const handleCtaClick = (event) => {
|
|
177
|
+
const target = event.target;
|
|
178
|
+
if (!(target instanceof Element))
|
|
179
|
+
return;
|
|
180
|
+
const cta = target.closest('[data-bl-cta]');
|
|
181
|
+
if (!cta)
|
|
182
|
+
return;
|
|
183
|
+
const ctaId = cta.getAttribute('data-bl-cta')?.trim();
|
|
184
|
+
const placement = leadCtaPlacementValue(cta.getAttribute('data-bl-cta-placement'));
|
|
185
|
+
if (!ctaId || !placement)
|
|
186
|
+
return;
|
|
187
|
+
const pagePath = win.location.pathname || '/';
|
|
188
|
+
const key = `${pagePath}|${ctaId}|${placement}`;
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
if (!shouldTrackPhoneCallClick({ key, now, lastEvent, dedupeWindowMs: options.dedupeWindowMs }))
|
|
191
|
+
return;
|
|
192
|
+
lastEvent = { key, at: now };
|
|
193
|
+
trackLeadCtaClick({ pagePath, ctaId, placement });
|
|
194
|
+
};
|
|
195
|
+
win.document.addEventListener('click', handleCtaClick, true);
|
|
196
|
+
return () => {
|
|
197
|
+
win.document.removeEventListener('click', handleCtaClick, true);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
155
200
|
const installedInstrumentation = new WeakMap();
|
|
156
201
|
/**
|
|
157
202
|
* The whole of `src/instrumentation-client.ts`:
|
|
158
203
|
* `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing
|
|
159
204
|
* path and visit source for contact attribution, and installs the single
|
|
160
205
|
* telephone-link listener that sends one `phone_link_click` per tap (rapid
|
|
161
|
-
* repeats of the same link are de-duplicated).
|
|
162
|
-
*
|
|
206
|
+
* repeats of the same link are de-duplicated). With the binding's
|
|
207
|
+
* `trackLeadCtaClick`, it also installs the `data-bl-cta` listener, which sends
|
|
208
|
+
* `lead_cta_click` only while lead diagnostics are on. Installing twice in one
|
|
209
|
+
* window is a no-op; the returned function removes the listeners.
|
|
163
210
|
*/
|
|
164
211
|
export function installBrandlabInstrumentation(analytics, options = {}) {
|
|
165
212
|
if (!analytics || typeof analytics.trackPhoneLinkClick !== 'function') {
|
|
@@ -184,8 +231,23 @@ export function installBrandlabInstrumentation(analytics, options = {}) {
|
|
|
184
231
|
}
|
|
185
232
|
},
|
|
186
233
|
});
|
|
234
|
+
const removeCtaListener = typeof analytics.trackLeadCtaClick === 'function'
|
|
235
|
+
? installLeadCtaClickListener({
|
|
236
|
+
win,
|
|
237
|
+
...(options.dedupeWindowMs !== undefined ? { dedupeWindowMs: options.dedupeWindowMs } : {}),
|
|
238
|
+
trackLeadCtaClick: (input) => {
|
|
239
|
+
try {
|
|
240
|
+
analytics.trackLeadCtaClick?.(input);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Tracking must never block the call to action.
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
: () => undefined;
|
|
187
248
|
const teardown = () => {
|
|
188
249
|
removeListener();
|
|
250
|
+
removeCtaListener();
|
|
189
251
|
if (installedInstrumentation.get(win) === teardown)
|
|
190
252
|
installedInstrumentation.delete(win);
|
|
191
253
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instrumentation-client.js","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isContactVisitSource,\n type ContactAttributionSummary,\n} from '@brandlab-digital/site-core/contact'\nimport {\n PHONE_CALL_PLACEMENTS,\n type BrandlabAnalytics,\n type PhoneCallPlacement,\n} from './analytics.js'\nimport {\n classifyContactAttribution,\n normalizeContactAttributionSummary,\n} from './contact/attribution.js'\n\nexport type PhoneCallLocation = PhoneCallPlacement\n\nexport interface LastPhoneCallEvent {\n key: string\n at: number\n}\n\nexport const INITIAL_LANDING_STORAGE_PREFIX = 'brandlab.initialLanding.'\nexport const DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS = 1200\n\nexport function normalizeInitialLandingPath(pathname: string | null | undefined) {\n const trimmed = pathname?.trim() || '/'\n if (\n !trimmed.startsWith('/') ||\n trimmed.startsWith('//') ||\n trimmed.includes('\\\\') ||\n /%(?:2f|5c)/i.test(trimmed) ||\n /[\\u0000-\\u001f\\u007f]/.test(trimmed) ||\n trimmed.includes('?') ||\n trimmed.includes('#')\n ) {\n return '/'\n }\n const normalized = trimmed.replace(/\\/{2,}/g, '/')\n return normalized === '/' ? '/' : normalized.replace(/\\/+$/, '')\n}\n\nexport function createPhoneCallDedupeKey(pagePath: string, href: string) {\n return `${pagePath}|${href}`\n}\n\nexport function shouldTrackPhoneCallClick(options: {\n key: string\n now: number\n lastEvent?: LastPhoneCallEvent\n dedupeWindowMs?: number\n}) {\n const windowMs = options.dedupeWindowMs ?? DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS\n return !(\n options.lastEvent &&\n options.lastEvent.key === options.key &&\n options.now - options.lastEvent.at < windowMs\n )\n}\n\nexport function persistInitialLanding(win: Window = window) {\n try {\n const pathKey = `${INITIAL_LANDING_STORAGE_PREFIX}path`\n const sourceKey = `${INITIAL_LANDING_STORAGE_PREFIX}source`\n const detailKey = `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`\n const existingPath = win.sessionStorage.getItem(pathKey)\n const safePath = normalizeInitialLandingPath(existingPath ?? win.location.pathname)\n if (existingPath !== safePath) {\n win.sessionStorage.setItem(pathKey, safePath)\n }\n const existingSource = win.sessionStorage.getItem(sourceKey)\n if (!isContactVisitSource(existingSource)) {\n const attribution = classifyContactAttribution({\n initialLandingPath: safePath,\n search: win.location.search,\n referrer: win.document.referrer,\n siteOrigin: win.location.origin,\n })\n win.sessionStorage.setItem(sourceKey, attribution.visitSource)\n if (attribution.visitSourceDetail) {\n win.sessionStorage.setItem(detailKey, attribution.visitSourceDetail)\n } else {\n win.sessionStorage.removeItem(detailKey)\n }\n }\n // Remove values written by the pre-RC privacy contract. Query strings and\n // referrers are not needed for canonical lead measurement.\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}url`)\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}referrer`)\n } catch {\n // Storage failures must not affect navigation or lead tracking.\n }\n}\n\nexport function getInitialLandingPath(win: Window = window) {\n try {\n const value = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`)\n return value ? normalizeInitialLandingPath(value) : undefined\n } catch {\n return undefined\n }\n}\n\nexport function getInitialContactAttribution(win: Window = window): ContactAttributionSummary {\n const fallbackPath = normalizeInitialLandingPath(win.location.pathname)\n try {\n const initialLandingPath = normalizeInitialLandingPath(\n win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`) ?? fallbackPath,\n )\n const visitSource = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}source`)\n const visitSourceDetail = win.sessionStorage.getItem(\n `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`,\n )\n return normalizeContactAttributionSummary({\n initialLandingPath,\n visitSource: isContactVisitSource(visitSource) ? visitSource : 'direct-or-unknown',\n ...(visitSourceDetail ? { visitSourceDetail } : {}),\n })\n } catch {\n return { initialLandingPath: fallbackPath, visitSource: 'direct-or-unknown' }\n }\n}\n\nfunction placementValue(value: string | null | undefined): PhoneCallLocation | undefined {\n const normalized = value?.trim().toLowerCase()\n return PHONE_CALL_PLACEMENTS.includes(normalized as PhoneCallLocation)\n ? normalized as PhoneCallLocation\n : undefined\n}\n\n/**\n * Placement of a telephone link: its own `data-phone-location`, then a\n * `<header>` or `<footer>` ancestor, then the nearest ancestor declaring a\n * valid `data-phone-location` (for example a hero or call-to-action section),\n * otherwise `page`. Unrecognised values are ignored, so every tap still\n * produces exactly one event.\n */\nexport function getPhoneCallLocation(link: HTMLAnchorElement): PhoneCallLocation {\n const explicit = placementValue(link.getAttribute('data-phone-location'))\n if (explicit) return explicit\n if (link.closest('header')) return 'header'\n if (link.closest('footer')) return 'footer'\n for (\n let ancestor = link.parentElement?.closest('[data-phone-location]');\n ancestor;\n ancestor = ancestor.parentElement?.closest('[data-phone-location]')\n ) {\n const placement = placementValue(ancestor.getAttribute('data-phone-location'))\n if (placement) return placement\n }\n return 'page'\n}\n\nexport function installPhoneClickListener(options: {\n trackPhoneLinkClick: (context: {\n pagePath: string\n location: PhoneCallLocation\n initialLandingPath?: string\n }) => void\n win?: Window\n dedupeWindowMs?: number\n}) {\n const win = options.win ?? window\n const trackPhoneLinkClick = options.trackPhoneLinkClick\n if (!trackPhoneLinkClick) {\n throw new Error('installPhoneClickListener requires trackPhoneLinkClick.')\n }\n let lastEvent: LastPhoneCallEvent | undefined\n\n const handlePhoneClick = (event: MouseEvent) => {\n const target = event.target\n if (!(target instanceof Element)) return\n\n const link = target.closest<HTMLAnchorElement>('a[href^=\"tel:\"]')\n if (!link) return\n\n const pagePath = win.location.pathname || '/'\n const href = link.getAttribute('href') || link.href\n const key = createPhoneCallDedupeKey(pagePath, href)\n const now = Date.now()\n\n if (\n !shouldTrackPhoneCallClick({\n key,\n now,\n lastEvent,\n dedupeWindowMs: options.dedupeWindowMs,\n })\n ) {\n return\n }\n\n lastEvent = { key, at: now }\n trackPhoneLinkClick({\n pagePath,\n location: getPhoneCallLocation(link),\n initialLandingPath: getInitialLandingPath(win),\n })\n }\n\n win.document.addEventListener('click', handlePhoneClick, true)\n\n return () => {\n win.document.removeEventListener('click', handlePhoneClick, true)\n }\n}\n\nconst installedInstrumentation = new WeakMap<Window, () => void>()\n\n/**\n * The whole of `src/instrumentation-client.ts`:\n * `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing\n * path and visit source for contact attribution, and installs the single\n * telephone-link listener that sends one `phone_link_click` per tap (rapid\n * repeats of the same link are de-duplicated). Installing twice in one window\n * is a no-op; the returned function removes the listener.\n */\nexport function installBrandlabInstrumentation(\n analytics: Pick<BrandlabAnalytics, 'trackPhoneLinkClick'>,\n options: { win?: Window; dedupeWindowMs?: number } = {},\n): () => void {\n if (!analytics || typeof analytics.trackPhoneLinkClick !== 'function') {\n throw new TypeError('installBrandlabInstrumentation requires the defineBrandlabAnalytics binding.')\n }\n const win = options.win ?? (typeof window === 'undefined' ? undefined : window)\n if (!win) return () => undefined\n const existing = installedInstrumentation.get(win)\n if (existing) return existing\n\n persistInitialLanding(win)\n const removeListener = installPhoneClickListener({\n win,\n ...(options.dedupeWindowMs !== undefined ? { dedupeWindowMs: options.dedupeWindowMs } : {}),\n trackPhoneLinkClick: ({ pagePath, location }) => {\n try {\n analytics.trackPhoneLinkClick({ pagePath, placement: location })\n } catch {\n // Tracking must never block the telephone action.\n }\n },\n })\n const teardown = () => {\n removeListener()\n if (installedInstrumentation.get(win) === teardown) installedInstrumentation.delete(win)\n }\n installedInstrumentation.set(win, teardown)\n return teardown\n}\n"]}
|
|
1
|
+
{"version":3,"file":"instrumentation-client.js","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isContactVisitSource,\n type ContactAttributionSummary,\n} from '@brandlab-digital/site-core/contact'\nimport {\n LEAD_CTA_PLACEMENTS,\n PHONE_CALL_PLACEMENTS,\n type BrandlabAnalytics,\n type LeadCtaPlacement,\n type PhoneCallPlacement,\n} from './analytics.js'\nimport {\n classifyContactAttribution,\n normalizeContactAttributionSummary,\n} from './contact/attribution.js'\n\nexport type PhoneCallLocation = PhoneCallPlacement\n\nexport interface LastPhoneCallEvent {\n key: string\n at: number\n}\n\nexport const INITIAL_LANDING_STORAGE_PREFIX = 'brandlab.initialLanding.'\nexport const DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS = 1200\n\nexport function normalizeInitialLandingPath(pathname: string | null | undefined) {\n const trimmed = pathname?.trim() || '/'\n if (\n !trimmed.startsWith('/') ||\n trimmed.startsWith('//') ||\n trimmed.includes('\\\\') ||\n /%(?:2f|5c)/i.test(trimmed) ||\n /[\\u0000-\\u001f\\u007f]/.test(trimmed) ||\n trimmed.includes('?') ||\n trimmed.includes('#')\n ) {\n return '/'\n }\n const normalized = trimmed.replace(/\\/{2,}/g, '/')\n return normalized === '/' ? '/' : normalized.replace(/\\/+$/, '')\n}\n\nexport function createPhoneCallDedupeKey(pagePath: string, href: string) {\n return `${pagePath}|${href}`\n}\n\nexport function shouldTrackPhoneCallClick(options: {\n key: string\n now: number\n lastEvent?: LastPhoneCallEvent\n dedupeWindowMs?: number\n}) {\n const windowMs = options.dedupeWindowMs ?? DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS\n return !(\n options.lastEvent &&\n options.lastEvent.key === options.key &&\n options.now - options.lastEvent.at < windowMs\n )\n}\n\nexport function persistInitialLanding(win: Window = window) {\n try {\n const pathKey = `${INITIAL_LANDING_STORAGE_PREFIX}path`\n const sourceKey = `${INITIAL_LANDING_STORAGE_PREFIX}source`\n const detailKey = `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`\n const existingPath = win.sessionStorage.getItem(pathKey)\n const safePath = normalizeInitialLandingPath(existingPath ?? win.location.pathname)\n if (existingPath !== safePath) {\n win.sessionStorage.setItem(pathKey, safePath)\n }\n const existingSource = win.sessionStorage.getItem(sourceKey)\n if (!isContactVisitSource(existingSource)) {\n const attribution = classifyContactAttribution({\n initialLandingPath: safePath,\n search: win.location.search,\n referrer: win.document.referrer,\n siteOrigin: win.location.origin,\n })\n win.sessionStorage.setItem(sourceKey, attribution.visitSource)\n if (attribution.visitSourceDetail) {\n win.sessionStorage.setItem(detailKey, attribution.visitSourceDetail)\n } else {\n win.sessionStorage.removeItem(detailKey)\n }\n }\n // Remove values written by the pre-RC privacy contract. Query strings and\n // referrers are not needed for canonical lead measurement.\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}url`)\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}referrer`)\n } catch {\n // Storage failures must not affect navigation or lead tracking.\n }\n}\n\nexport function getInitialLandingPath(win: Window = window) {\n try {\n const value = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`)\n return value ? normalizeInitialLandingPath(value) : undefined\n } catch {\n return undefined\n }\n}\n\nexport function getInitialContactAttribution(win: Window = window): ContactAttributionSummary {\n const fallbackPath = normalizeInitialLandingPath(win.location.pathname)\n try {\n const initialLandingPath = normalizeInitialLandingPath(\n win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`) ?? fallbackPath,\n )\n const visitSource = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}source`)\n const visitSourceDetail = win.sessionStorage.getItem(\n `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`,\n )\n return normalizeContactAttributionSummary({\n initialLandingPath,\n visitSource: isContactVisitSource(visitSource) ? visitSource : 'direct-or-unknown',\n ...(visitSourceDetail ? { visitSourceDetail } : {}),\n })\n } catch {\n return { initialLandingPath: fallbackPath, visitSource: 'direct-or-unknown' }\n }\n}\n\nfunction placementValue(value: string | null | undefined): PhoneCallLocation | undefined {\n const normalized = value?.trim().toLowerCase()\n return PHONE_CALL_PLACEMENTS.includes(normalized as PhoneCallLocation)\n ? normalized as PhoneCallLocation\n : undefined\n}\n\n/**\n * Placement of a telephone link: its own `data-phone-location`, then a\n * `<header>` or `<footer>` ancestor, then the nearest ancestor declaring a\n * valid `data-phone-location` (for example a hero or call-to-action section),\n * otherwise `page`. Unrecognised values are ignored, so every tap still\n * produces exactly one event.\n */\nexport function getPhoneCallLocation(link: HTMLAnchorElement): PhoneCallLocation {\n const explicit = placementValue(link.getAttribute('data-phone-location'))\n if (explicit) return explicit\n if (link.closest('header')) return 'header'\n if (link.closest('footer')) return 'footer'\n for (\n let ancestor = link.parentElement?.closest('[data-phone-location]');\n ancestor;\n ancestor = ancestor.parentElement?.closest('[data-phone-location]')\n ) {\n const placement = placementValue(ancestor.getAttribute('data-phone-location'))\n if (placement) return placement\n }\n return 'page'\n}\n\nexport function installPhoneClickListener(options: {\n trackPhoneLinkClick: (context: {\n pagePath: string\n location: PhoneCallLocation\n initialLandingPath?: string\n }) => void\n win?: Window\n dedupeWindowMs?: number\n}) {\n const win = options.win ?? window\n const trackPhoneLinkClick = options.trackPhoneLinkClick\n if (!trackPhoneLinkClick) {\n throw new Error('installPhoneClickListener requires trackPhoneLinkClick.')\n }\n let lastEvent: LastPhoneCallEvent | undefined\n\n const handlePhoneClick = (event: MouseEvent) => {\n const target = event.target\n if (!(target instanceof Element)) return\n\n const link = target.closest<HTMLAnchorElement>('a[href^=\"tel:\"]')\n if (!link) return\n\n const pagePath = win.location.pathname || '/'\n const href = link.getAttribute('href') || link.href\n const key = createPhoneCallDedupeKey(pagePath, href)\n const now = Date.now()\n\n if (\n !shouldTrackPhoneCallClick({\n key,\n now,\n lastEvent,\n dedupeWindowMs: options.dedupeWindowMs,\n })\n ) {\n return\n }\n\n lastEvent = { key, at: now }\n trackPhoneLinkClick({\n pagePath,\n location: getPhoneCallLocation(link),\n initialLandingPath: getInitialLandingPath(win),\n })\n }\n\n win.document.addEventListener('click', handlePhoneClick, true)\n\n return () => {\n win.document.removeEventListener('click', handlePhoneClick, true)\n }\n}\n\nfunction leadCtaPlacementValue(value: string | null | undefined): LeadCtaPlacement | undefined {\n const normalized = value?.trim().toLowerCase()\n return LEAD_CTA_PLACEMENTS.includes(normalized as LeadCtaPlacement)\n ? normalized as LeadCtaPlacement\n : undefined\n}\n\n/**\n * The single delegated listener for lead calls to action: a click on an\n * element carrying `data-bl-cta` (the ui-kit `QuoteButton`) sends one\n * `lead_cta_click` with its cta id and its `data-bl-cta-placement`. Elements\n * without an id or with a placement outside `LEAD_CTA_PLACEMENTS` send nothing.\n * Rapid repeats of the same call to action on the same page are de-duplicated\n * with the phone listener's window.\n */\nexport function installLeadCtaClickListener(options: {\n trackLeadCtaClick: (context: {\n pagePath: string\n ctaId: string\n placement: LeadCtaPlacement\n }) => void\n win?: Window\n dedupeWindowMs?: number\n}) {\n const win = options.win ?? window\n const trackLeadCtaClick = options.trackLeadCtaClick\n if (!trackLeadCtaClick) {\n throw new Error('installLeadCtaClickListener requires trackLeadCtaClick.')\n }\n\n let lastEvent: LastPhoneCallEvent | undefined\n\n const handleCtaClick = (event: MouseEvent) => {\n const target = event.target\n if (!(target instanceof Element)) return\n const cta = target.closest<HTMLElement>('[data-bl-cta]')\n if (!cta) return\n const ctaId = cta.getAttribute('data-bl-cta')?.trim()\n const placement = leadCtaPlacementValue(cta.getAttribute('data-bl-cta-placement'))\n if (!ctaId || !placement) return\n const pagePath = win.location.pathname || '/'\n const key = `${pagePath}|${ctaId}|${placement}`\n const now = Date.now()\n if (!shouldTrackPhoneCallClick({ key, now, lastEvent, dedupeWindowMs: options.dedupeWindowMs })) return\n lastEvent = { key, at: now }\n trackLeadCtaClick({ pagePath, ctaId, placement })\n }\n\n win.document.addEventListener('click', handleCtaClick, true)\n\n return () => {\n win.document.removeEventListener('click', handleCtaClick, true)\n }\n}\n\nconst installedInstrumentation = new WeakMap<Window, () => void>()\n\n/**\n * The whole of `src/instrumentation-client.ts`:\n * `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing\n * path and visit source for contact attribution, and installs the single\n * telephone-link listener that sends one `phone_link_click` per tap (rapid\n * repeats of the same link are de-duplicated). With the binding's\n * `trackLeadCtaClick`, it also installs the `data-bl-cta` listener, which sends\n * `lead_cta_click` only while lead diagnostics are on. Installing twice in one\n * window is a no-op; the returned function removes the listeners.\n */\nexport function installBrandlabInstrumentation(\n analytics: Pick<BrandlabAnalytics, 'trackPhoneLinkClick'> &\n Partial<Pick<BrandlabAnalytics, 'trackLeadCtaClick'>>,\n options: { win?: Window; dedupeWindowMs?: number } = {},\n): () => void {\n if (!analytics || typeof analytics.trackPhoneLinkClick !== 'function') {\n throw new TypeError('installBrandlabInstrumentation requires the defineBrandlabAnalytics binding.')\n }\n const win = options.win ?? (typeof window === 'undefined' ? undefined : window)\n if (!win) return () => undefined\n const existing = installedInstrumentation.get(win)\n if (existing) return existing\n\n persistInitialLanding(win)\n const removeListener = installPhoneClickListener({\n win,\n ...(options.dedupeWindowMs !== undefined ? { dedupeWindowMs: options.dedupeWindowMs } : {}),\n trackPhoneLinkClick: ({ pagePath, location }) => {\n try {\n analytics.trackPhoneLinkClick({ pagePath, placement: location })\n } catch {\n // Tracking must never block the telephone action.\n }\n },\n })\n const removeCtaListener = typeof analytics.trackLeadCtaClick === 'function'\n ? installLeadCtaClickListener({\n win,\n ...(options.dedupeWindowMs !== undefined ? { dedupeWindowMs: options.dedupeWindowMs } : {}),\n trackLeadCtaClick: (input) => {\n try {\n analytics.trackLeadCtaClick?.(input)\n } catch {\n // Tracking must never block the call to action.\n }\n },\n })\n : () => undefined\n const teardown = () => {\n removeListener()\n removeCtaListener()\n if (installedInstrumentation.get(win) === teardown) installedInstrumentation.delete(win)\n }\n installedInstrumentation.set(win, teardown)\n return teardown\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's timings, breakpoints and hooks as plain values (no React), so server components can read them. A site
|
|
3
|
+
* can't change them.
|
|
4
|
+
*/
|
|
5
|
+
/** F3: a fine mouse opens a panel 100 ms after it enters an item and closes it 200 ms after it leaves. */
|
|
6
|
+
export declare const NAV_HOVER_OPEN_DELAY_MS = 100;
|
|
7
|
+
export declare const NAV_HOVER_CLOSE_DELAY_MS = 200;
|
|
8
|
+
/** Only a fine mouse pointer that can hover opens panels on hover. */
|
|
9
|
+
export declare const NAV_HOVER_MEDIA_QUERY = "(hover: hover) and (pointer: fine)";
|
|
10
|
+
/** F2: header modes by the header's own width. */
|
|
11
|
+
export declare const HEADER_DESKTOP_MIN_WIDTH = 1280;
|
|
12
|
+
export declare const HEADER_COMPACT_MIN_WIDTH = 1024;
|
|
13
|
+
/** With `compactAfterScroll`, the header compacts once the page has scrolled this far. */
|
|
14
|
+
export declare const HEADER_COMPACT_AFTER_SCROLL_PX = 80;
|
|
15
|
+
export type HeaderMode = 'phone' | 'compact' | 'desktop';
|
|
16
|
+
export declare function headerModeFor(width: number): HeaderMode;
|
|
17
|
+
/** F6: the call bar shows below this viewport width. */
|
|
18
|
+
export declare const CALL_BAR_MEDIA_QUERY = "(max-width: 1023.98px)";
|
|
19
|
+
/** F6: the hero's own call and quote buttons (and any other call path) carry this marker. */
|
|
20
|
+
export declare const CALL_PATH_SELECTOR = "[data-bl-call-path]";
|
|
21
|
+
/** F7: back-to-top shows after this many screens of scrolling. */
|
|
22
|
+
export declare const BACK_TO_TOP_AFTER_SCREENS = 1.5;
|
|
23
|
+
/** F12: the skip-link target, which back-to-top also focuses. */
|
|
24
|
+
export declare const MAIN_CONTENT_ID = "main";
|
|
25
|
+
//# sourceMappingURL=constants.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/navigation/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,uBAAuB,MAAM,CAAA;AAC1C,eAAO,MAAM,wBAAwB,MAAM,CAAA;AAC3C,sEAAsE;AACtE,eAAO,MAAM,qBAAqB,uCAAuC,CAAA;AAEzE,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,OAAO,CAAA;AAC5C,eAAO,MAAM,wBAAwB,OAAO,CAAA;AAC5C,0FAA0F;AAC1F,eAAO,MAAM,8BAA8B,KAAK,CAAA;AAEhD,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAA;AAExD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAIvD;AAED,wDAAwD;AACxD,eAAO,MAAM,oBAAoB,2BAA2B,CAAA;AAC5D,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB,wBAAwB,CAAA;AAEvD,kEAAkE;AAClE,eAAO,MAAM,yBAAyB,MAAM,CAAA;AAC5C,iEAAiE;AACjE,eAAO,MAAM,eAAe,SAAS,CAAA"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's timings, breakpoints and hooks as plain values (no React), so server components can read them. A site
|
|
3
|
+
* can't change them.
|
|
4
|
+
*/
|
|
5
|
+
/** F3: a fine mouse opens a panel 100 ms after it enters an item and closes it 200 ms after it leaves. */
|
|
6
|
+
export const NAV_HOVER_OPEN_DELAY_MS = 100;
|
|
7
|
+
export const NAV_HOVER_CLOSE_DELAY_MS = 200;
|
|
8
|
+
/** Only a fine mouse pointer that can hover opens panels on hover. */
|
|
9
|
+
export const NAV_HOVER_MEDIA_QUERY = '(hover: hover) and (pointer: fine)';
|
|
10
|
+
/** F2: header modes by the header's own width. */
|
|
11
|
+
export const HEADER_DESKTOP_MIN_WIDTH = 1280;
|
|
12
|
+
export const HEADER_COMPACT_MIN_WIDTH = 1024;
|
|
13
|
+
/** With `compactAfterScroll`, the header compacts once the page has scrolled this far. */
|
|
14
|
+
export const HEADER_COMPACT_AFTER_SCROLL_PX = 80;
|
|
15
|
+
export function headerModeFor(width) {
|
|
16
|
+
if (width >= HEADER_DESKTOP_MIN_WIDTH)
|
|
17
|
+
return 'desktop';
|
|
18
|
+
if (width >= HEADER_COMPACT_MIN_WIDTH)
|
|
19
|
+
return 'compact';
|
|
20
|
+
return 'phone';
|
|
21
|
+
}
|
|
22
|
+
/** F6: the call bar shows below this viewport width. */
|
|
23
|
+
export const CALL_BAR_MEDIA_QUERY = '(max-width: 1023.98px)';
|
|
24
|
+
/** F6: the hero's own call and quote buttons (and any other call path) carry this marker. */
|
|
25
|
+
export const CALL_PATH_SELECTOR = '[data-bl-call-path]';
|
|
26
|
+
/** F7: back-to-top shows after this many screens of scrolling. */
|
|
27
|
+
export const BACK_TO_TOP_AFTER_SCREENS = 1.5;
|
|
28
|
+
/** F12: the skip-link target, which back-to-top also focuses. */
|
|
29
|
+
export const MAIN_CONTENT_ID = 'main';
|
|
30
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/navigation/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAA;AAC1C,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAA;AAC3C,sEAAsE;AACtE,MAAM,CAAC,MAAM,qBAAqB,GAAG,oCAAoC,CAAA;AAEzE,kDAAkD;AAClD,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,CAAA;AAC5C,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,CAAA;AAC5C,0FAA0F;AAC1F,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,CAAA;AAIhD,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAI,KAAK,IAAI,wBAAwB;QAAE,OAAO,SAAS,CAAA;IACvD,IAAI,KAAK,IAAI,wBAAwB;QAAE,OAAO,SAAS,CAAA;IACvD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,wDAAwD;AACxD,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAA;AAC5D,6FAA6F;AAC7F,MAAM,CAAC,MAAM,kBAAkB,GAAG,qBAAqB,CAAA;AAEvD,kEAAkE;AAClE,MAAM,CAAC,MAAM,yBAAyB,GAAG,GAAG,CAAA;AAC5C,iEAAiE;AACjE,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAA","sourcesContent":["/**\n * The shell's timings, breakpoints and hooks as plain values (no React), so server components can read them. A site\n * can't change them.\n */\n\n/** F3: a fine mouse opens a panel 100 ms after it enters an item and closes it 200 ms after it leaves. */\nexport const NAV_HOVER_OPEN_DELAY_MS = 100\nexport const NAV_HOVER_CLOSE_DELAY_MS = 200\n/** Only a fine mouse pointer that can hover opens panels on hover. */\nexport const NAV_HOVER_MEDIA_QUERY = '(hover: hover) and (pointer: fine)'\n\n/** F2: header modes by the header's own width. */\nexport const HEADER_DESKTOP_MIN_WIDTH = 1280\nexport const HEADER_COMPACT_MIN_WIDTH = 1024\n/** With `compactAfterScroll`, the header compacts once the page has scrolled this far. */\nexport const HEADER_COMPACT_AFTER_SCROLL_PX = 80\n\nexport type HeaderMode = 'phone' | 'compact' | 'desktop'\n\nexport function headerModeFor(width: number): HeaderMode {\n if (width >= HEADER_DESKTOP_MIN_WIDTH) return 'desktop'\n if (width >= HEADER_COMPACT_MIN_WIDTH) return 'compact'\n return 'phone'\n}\n\n/** F6: the call bar shows below this viewport width. */\nexport const CALL_BAR_MEDIA_QUERY = '(max-width: 1023.98px)'\n/** F6: the hero's own call and quote buttons (and any other call path) carry this marker. */\nexport const CALL_PATH_SELECTOR = '[data-bl-call-path]'\n\n/** F7: back-to-top shows after this many screens of scrolling. */\nexport const BACK_TO_TOP_AFTER_SCREENS = 1.5\n/** F12: the skip-link target, which back-to-top also focuses. */\nexport const MAIN_CONTENT_ID = 'main'\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@brandlab-digital/site-next/navigation`: the client hooks behind the ui-kit shell, and its timings and breakpoints.
|
|
3
|
+
* The ui-kit components use the hooks; a site uses them directly only in a recorded bespoke header. The hook modules
|
|
4
|
+
* are client modules; the constants are plain values a server component can read.
|
|
5
|
+
*/
|
|
6
|
+
export { BACK_TO_TOP_AFTER_SCREENS, CALL_BAR_MEDIA_QUERY, CALL_PATH_SELECTOR, HEADER_COMPACT_AFTER_SCROLL_PX, HEADER_COMPACT_MIN_WIDTH, HEADER_DESKTOP_MIN_WIDTH, MAIN_CONTENT_ID, NAV_HOVER_CLOSE_DELAY_MS, NAV_HOVER_MEDIA_QUERY, NAV_HOVER_OPEN_DELAY_MS, headerModeFor, type HeaderMode, } from './constants.js';
|
|
7
|
+
export { useNavigationMenu, type NavigationMenu, type NavigationOpenedBy } from './use-navigation-menu.js';
|
|
8
|
+
export { useHeaderMetrics, type HeaderMetrics } from './use-header-metrics.js';
|
|
9
|
+
export { useStickyCallBar } from './use-sticky-call-bar.js';
|
|
10
|
+
export { useBackToTop } from './use-back-to-top.js';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/navigation/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,kBAAkB,EAClB,8BAA8B,EAC9B,wBAAwB,EACxB,wBAAwB,EACxB,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,uBAAuB,EACvB,aAAa,EACb,KAAK,UAAU,GAChB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,iBAAiB,EAAE,KAAK,cAAc,EAAE,KAAK,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAC1G,OAAO,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAC9E,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@brandlab-digital/site-next/navigation`: the client hooks behind the ui-kit shell, and its timings and breakpoints.
|
|
3
|
+
* The ui-kit components use the hooks; a site uses them directly only in a recorded bespoke header. The hook modules
|
|
4
|
+
* are client modules; the constants are plain values a server component can read.
|
|
5
|
+
*/
|
|
6
|
+
export { BACK_TO_TOP_AFTER_SCREENS, CALL_BAR_MEDIA_QUERY, CALL_PATH_SELECTOR, HEADER_COMPACT_AFTER_SCROLL_PX, HEADER_COMPACT_MIN_WIDTH, HEADER_DESKTOP_MIN_WIDTH, MAIN_CONTENT_ID, NAV_HOVER_CLOSE_DELAY_MS, NAV_HOVER_MEDIA_QUERY, NAV_HOVER_OPEN_DELAY_MS, headerModeFor, } from './constants.js';
|
|
7
|
+
export { useNavigationMenu } from './use-navigation-menu.js';
|
|
8
|
+
export { useHeaderMetrics } from './use-header-metrics.js';
|
|
9
|
+
export { useStickyCallBar } from './use-sticky-call-bar.js';
|
|
10
|
+
export { useBackToTop } from './use-back-to-top.js';
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/navigation/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,kBAAkB,EAClB,8BAA8B,EAC9B,wBAAwB,EACxB,wBAAwB,EACxB,eAAe,EACf,wBAAwB,EACxB,qBAAqB,EACrB,uBAAuB,EACvB,aAAa,GAEd,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,iBAAiB,EAAgD,MAAM,0BAA0B,CAAA;AAC1G,OAAO,EAAE,gBAAgB,EAAsB,MAAM,yBAAyB,CAAA;AAC9E,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA","sourcesContent":["/**\n * `@brandlab-digital/site-next/navigation`: the client hooks behind the ui-kit shell, and its timings and breakpoints.\n * The ui-kit components use the hooks; a site uses them directly only in a recorded bespoke header. The hook modules\n * are client modules; the constants are plain values a server component can read.\n */\nexport {\n BACK_TO_TOP_AFTER_SCREENS,\n CALL_BAR_MEDIA_QUERY,\n CALL_PATH_SELECTOR,\n HEADER_COMPACT_AFTER_SCROLL_PX,\n HEADER_COMPACT_MIN_WIDTH,\n HEADER_DESKTOP_MIN_WIDTH,\n MAIN_CONTENT_ID,\n NAV_HOVER_CLOSE_DELAY_MS,\n NAV_HOVER_MEDIA_QUERY,\n NAV_HOVER_OPEN_DELAY_MS,\n headerModeFor,\n type HeaderMode,\n} from './constants.js'\nexport { useNavigationMenu, type NavigationMenu, type NavigationOpenedBy } from './use-navigation-menu.js'\nexport { useHeaderMetrics, type HeaderMetrics } from './use-header-metrics.js'\nexport { useStickyCallBar } from './use-sticky-call-bar.js'\nexport { useBackToTop } from './use-back-to-top.js'\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-back-to-top.d.ts","sourceRoot":"","sources":["../../src/navigation/use-back-to-top.ts"],"names":[],"mappings":"AASA,wBAAgB,YAAY,IAAI;IAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAAC,WAAW,IAAI,IAAI,CAAA;CAAE,CAwB/E"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* F7: back-to-top. It shows after one and a half screens of scrolling; it scrolls smoothly unless the visitor prefers
|
|
4
|
+
* reduced motion, and moves focus to the skip-link target (`#main`), so keyboard users start again at the top.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
7
|
+
import { BACK_TO_TOP_AFTER_SCREENS, MAIN_CONTENT_ID } from './constants.js';
|
|
8
|
+
export function useBackToTop() {
|
|
9
|
+
const [shown, setShown] = useState(false);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const update = () => setShown(window.scrollY > window.innerHeight * BACK_TO_TOP_AFTER_SCREENS);
|
|
12
|
+
update();
|
|
13
|
+
window.addEventListener('scroll', update, { passive: true });
|
|
14
|
+
window.addEventListener('resize', update);
|
|
15
|
+
return () => {
|
|
16
|
+
window.removeEventListener('scroll', update);
|
|
17
|
+
window.removeEventListener('resize', update);
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
const scrollToTop = useCallback(() => {
|
|
21
|
+
const reduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
22
|
+
window.scrollTo({ top: 0, behavior: reduced ? 'auto' : 'smooth' });
|
|
23
|
+
const main = document.getElementById(MAIN_CONTENT_ID);
|
|
24
|
+
if (!main)
|
|
25
|
+
return;
|
|
26
|
+
if (!main.hasAttribute('tabindex'))
|
|
27
|
+
main.setAttribute('tabindex', '-1');
|
|
28
|
+
main.focus({ preventScroll: true });
|
|
29
|
+
}, []);
|
|
30
|
+
return { shown, scrollToTop };
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=use-back-to-top.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-back-to-top.js","sourceRoot":"","sources":["../../src/navigation/use-back-to-top.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;GAGG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACxD,OAAO,EAAE,yBAAyB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAE3E,MAAM,UAAU,YAAY;IAC1B,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAEzC,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,MAAM,GAAG,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,GAAG,MAAM,CAAC,WAAW,GAAG,yBAAyB,CAAC,CAAA;QAC9F,MAAM,EAAE,CAAA;QACR,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC5D,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;QACzC,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;YAC5C,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;QAC9C,CAAC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,MAAM,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE;QACnC,MAAM,OAAO,GAAG,OAAO,MAAM,CAAC,UAAU,KAAK,UAAU,IAAI,MAAM,CAAC,UAAU,CAAC,kCAAkC,CAAC,CAAC,OAAO,CAAA;QACxH,MAAM,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAA;QAClE,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAA;QACrD,IAAI,CAAC,IAAI;YAAE,OAAM;QACjB,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC;YAAE,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE,IAAI,CAAC,CAAA;QACvE,IAAI,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAA;IACrC,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,CAAA;AAC/B,CAAC","sourcesContent":["'use client'\n\n/**\n * F7: back-to-top. It shows after one and a half screens of scrolling; it scrolls smoothly unless the visitor prefers\n * reduced motion, and moves focus to the skip-link target (`#main`), so keyboard users start again at the top.\n */\nimport { useCallback, useEffect, useState } from 'react'\nimport { BACK_TO_TOP_AFTER_SCREENS, MAIN_CONTENT_ID } from './constants.js'\n\nexport function useBackToTop(): { readonly shown: boolean; scrollToTop(): void } {\n const [shown, setShown] = useState(false)\n\n useEffect(() => {\n const update = () => setShown(window.scrollY > window.innerHeight * BACK_TO_TOP_AFTER_SCREENS)\n update()\n window.addEventListener('scroll', update, { passive: true })\n window.addEventListener('resize', update)\n return () => {\n window.removeEventListener('scroll', update)\n window.removeEventListener('resize', update)\n }\n }, [])\n\n const scrollToTop = useCallback(() => {\n const reduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches\n window.scrollTo({ top: 0, behavior: reduced ? 'auto' : 'smooth' })\n const main = document.getElementById(MAIN_CONTENT_ID)\n if (!main) return\n if (!main.hasAttribute('tabindex')) main.setAttribute('tabindex', '-1')\n main.focus({ preventScroll: true })\n }, [])\n\n return { shown, scrollToTop }\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F2: the header's measured state. The mode follows the header's own width (so a framed specimen behaves like a real
|
|
3
|
+
* window): 1280 px and up `desktop`, 1024 to 1279 px `compact`, below 1024 px `phone`. The hook writes the frozen
|
|
4
|
+
* hooks on the header root (`data-mode`, `data-hydrated`, `data-compact`) and `--bl-header-height` on the document,
|
|
5
|
+
* which drives `scroll-padding-top` (WCAG 2.4.11).
|
|
6
|
+
*/
|
|
7
|
+
import { type RefObject } from 'react';
|
|
8
|
+
import { type HeaderMode } from './constants.js';
|
|
9
|
+
export interface HeaderMetrics {
|
|
10
|
+
/** Null until the header has been measured after hydration. */
|
|
11
|
+
readonly mode: HeaderMode | null;
|
|
12
|
+
readonly hydrated: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function useHeaderMetrics(rootRef: RefObject<HTMLElement | null>, options?: {
|
|
15
|
+
compactAfterScroll?: boolean;
|
|
16
|
+
}): HeaderMetrics;
|
|
17
|
+
//# sourceMappingURL=use-header-metrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-header-metrics.d.ts","sourceRoot":"","sources":["../../src/navigation/use-header-metrics.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,OAAO,EAAuB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC3D,OAAO,EAAiD,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAE/F,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAAE,OAAO,GAAE;IAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,aAAa,CAyCtI"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* F2: the header's measured state. The mode follows the header's own width (so a framed specimen behaves like a real
|
|
4
|
+
* window): 1280 px and up `desktop`, 1024 to 1279 px `compact`, below 1024 px `phone`. The hook writes the frozen
|
|
5
|
+
* hooks on the header root (`data-mode`, `data-hydrated`, `data-compact`) and `--bl-header-height` on the document,
|
|
6
|
+
* which drives `scroll-padding-top` (WCAG 2.4.11).
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
import { HEADER_COMPACT_AFTER_SCROLL_PX, headerModeFor } from './constants.js';
|
|
10
|
+
export function useHeaderMetrics(rootRef, options = {}) {
|
|
11
|
+
const compactAfterScroll = options.compactAfterScroll ?? false;
|
|
12
|
+
const [metrics, setMetrics] = useState({ mode: null, hydrated: false });
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const root = rootRef.current;
|
|
15
|
+
if (!root)
|
|
16
|
+
return;
|
|
17
|
+
const html = document.documentElement;
|
|
18
|
+
const measure = () => {
|
|
19
|
+
const box = root.getBoundingClientRect();
|
|
20
|
+
const mode = headerModeFor(box.width);
|
|
21
|
+
root.dataset.mode = mode;
|
|
22
|
+
html.style.setProperty('--bl-header-height', `${Math.round(box.height)}px`);
|
|
23
|
+
setMetrics((current) => (current.mode === mode && current.hydrated ? current : { mode, hydrated: true }));
|
|
24
|
+
};
|
|
25
|
+
const onScroll = () => {
|
|
26
|
+
root.dataset.compact = window.scrollY > HEADER_COMPACT_AFTER_SCROLL_PX ? 'true' : 'false';
|
|
27
|
+
};
|
|
28
|
+
root.dataset.hydrated = 'true';
|
|
29
|
+
measure();
|
|
30
|
+
let observer;
|
|
31
|
+
if (typeof ResizeObserver === 'function') {
|
|
32
|
+
observer = new ResizeObserver(measure);
|
|
33
|
+
observer.observe(root);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
window.addEventListener('resize', measure);
|
|
37
|
+
}
|
|
38
|
+
if (compactAfterScroll) {
|
|
39
|
+
onScroll();
|
|
40
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
41
|
+
}
|
|
42
|
+
return () => {
|
|
43
|
+
observer?.disconnect();
|
|
44
|
+
window.removeEventListener('resize', measure);
|
|
45
|
+
window.removeEventListener('scroll', onScroll);
|
|
46
|
+
delete root.dataset.compact;
|
|
47
|
+
html.style.removeProperty('--bl-header-height');
|
|
48
|
+
};
|
|
49
|
+
}, [rootRef, compactAfterScroll]);
|
|
50
|
+
return metrics;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=use-header-metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-header-metrics.js","sourceRoot":"","sources":["../../src/navigation/use-header-metrics.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;;GAKG;AACH,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAkB,MAAM,OAAO,CAAA;AAC3D,OAAO,EAAE,8BAA8B,EAAE,aAAa,EAAmB,MAAM,gBAAgB,CAAA;AAQ/F,MAAM,UAAU,gBAAgB,CAAC,OAAsC,EAAE,UAA4C,EAAE;IACrH,MAAM,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,IAAI,KAAK,CAAA;IAC9D,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAgB,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAA;IAEtF,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;QAC5B,IAAI,CAAC,IAAI;YAAE,OAAM;QACjB,MAAM,IAAI,GAAG,QAAQ,CAAC,eAAe,CAAA;QACrC,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,MAAM,GAAG,GAAG,IAAI,CAAC,qBAAqB,EAAE,CAAA;YACxC,MAAM,IAAI,GAAG,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YACrC,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,IAAI,CAAA;YACxB,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;YAC3E,UAAU,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QAC3G,CAAC,CAAA;QACD,MAAM,QAAQ,GAAG,GAAG,EAAE;YACpB,IAAI,CAAC,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,GAAG,8BAA8B,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAA;QAC3F,CAAC,CAAA;QACD,IAAI,CAAC,OAAO,CAAC,QAAQ,GAAG,MAAM,CAAA;QAC9B,OAAO,EAAE,CAAA;QACT,IAAI,QAAoC,CAAA;QACxC,IAAI,OAAO,cAAc,KAAK,UAAU,EAAE,CAAC;YACzC,QAAQ,GAAG,IAAI,cAAc,CAAC,OAAO,CAAC,CAAA;YACtC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;QACxB,CAAC;aAAM,CAAC;YACN,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;QAC5C,CAAC;QACD,IAAI,kBAAkB,EAAE,CAAC;YACvB,QAAQ,EAAE,CAAA;YACV,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAChE,CAAC;QACD,OAAO,GAAG,EAAE;YACV,QAAQ,EAAE,UAAU,EAAE,CAAA;YACtB,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;YAC7C,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;YAC9C,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAA;YAC3B,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,oBAAoB,CAAC,CAAA;QACjD,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,OAAO,EAAE,kBAAkB,CAAC,CAAC,CAAA;IAEjC,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["'use client'\n\n/**\n * F2: the header's measured state. The mode follows the header's own width (so a framed specimen behaves like a real\n * window): 1280 px and up `desktop`, 1024 to 1279 px `compact`, below 1024 px `phone`. The hook writes the frozen\n * hooks on the header root (`data-mode`, `data-hydrated`, `data-compact`) and `--bl-header-height` on the document,\n * which drives `scroll-padding-top` (WCAG 2.4.11).\n */\nimport { useEffect, useState, type RefObject } from 'react'\nimport { HEADER_COMPACT_AFTER_SCROLL_PX, headerModeFor, type HeaderMode } from './constants.js'\n\nexport interface HeaderMetrics {\n /** Null until the header has been measured after hydration. */\n readonly mode: HeaderMode | null\n readonly hydrated: boolean\n}\n\nexport function useHeaderMetrics(rootRef: RefObject<HTMLElement | null>, options: { compactAfterScroll?: boolean } = {}): HeaderMetrics {\n const compactAfterScroll = options.compactAfterScroll ?? false\n const [metrics, setMetrics] = useState<HeaderMetrics>({ mode: null, hydrated: false })\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n const html = document.documentElement\n const measure = () => {\n const box = root.getBoundingClientRect()\n const mode = headerModeFor(box.width)\n root.dataset.mode = mode\n html.style.setProperty('--bl-header-height', `${Math.round(box.height)}px`)\n setMetrics((current) => (current.mode === mode && current.hydrated ? current : { mode, hydrated: true }))\n }\n const onScroll = () => {\n root.dataset.compact = window.scrollY > HEADER_COMPACT_AFTER_SCROLL_PX ? 'true' : 'false'\n }\n root.dataset.hydrated = 'true'\n measure()\n let observer: ResizeObserver | undefined\n if (typeof ResizeObserver === 'function') {\n observer = new ResizeObserver(measure)\n observer.observe(root)\n } else {\n window.addEventListener('resize', measure)\n }\n if (compactAfterScroll) {\n onScroll()\n window.addEventListener('scroll', onScroll, { passive: true })\n }\n return () => {\n observer?.disconnect()\n window.removeEventListener('resize', measure)\n window.removeEventListener('scroll', onScroll)\n delete root.dataset.compact\n html.style.removeProperty('--bl-header-height')\n }\n }, [rootRef, compactAfterScroll])\n\n return metrics\n}\n"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3: the dropdown engine. The WAI-ARIA disclosure navigation pattern with top-level links and separate disclosure
|
|
3
|
+
* buttons, and the whole v1 §5.6 state table. Timings and rules are constants of the library; a site can't change
|
|
4
|
+
* them. The engine reads the DOM by hooks only (`data-slot="trigger"` and `data-slot="panel"` with `data-menu-id`),
|
|
5
|
+
* so the links and panels stay server-rendered.
|
|
6
|
+
*/
|
|
7
|
+
import { type FocusEvent, type KeyboardEvent, type MouseEvent, type PointerEvent, type RefObject } from 'react';
|
|
8
|
+
export type NavigationOpenedBy = 'hover' | 'click' | 'touch' | 'keyboard';
|
|
9
|
+
export interface NavigationMenu {
|
|
10
|
+
/** The open panel's menu id, or null. One panel is open at a time. */
|
|
11
|
+
readonly openId: string | null;
|
|
12
|
+
readonly openedBy: NavigationOpenedBy | null;
|
|
13
|
+
close(): void;
|
|
14
|
+
/** Spread on the item (`<li>`): hover intent and Tab out. */
|
|
15
|
+
itemHandlers(id: string): {
|
|
16
|
+
onPointerEnter(event: PointerEvent): void;
|
|
17
|
+
onPointerLeave(event: PointerEvent): void;
|
|
18
|
+
onPointerDown(event: PointerEvent): void;
|
|
19
|
+
onBlur(event: FocusEvent<HTMLElement>): void;
|
|
20
|
+
};
|
|
21
|
+
/** Spread on the disclosure button: click, Enter and Space toggle; Arrow Down opens and enters the panel. */
|
|
22
|
+
triggerHandlers(id: string): {
|
|
23
|
+
onClick(event: MouseEvent<HTMLButtonElement>): void;
|
|
24
|
+
onKeyDown(event: KeyboardEvent<HTMLButtonElement>): void;
|
|
25
|
+
};
|
|
26
|
+
/** Spread on the panel: Arrow Up, Arrow Down, Home and End move between its links; a link click closes. */
|
|
27
|
+
panelHandlers(id: string): {
|
|
28
|
+
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
|
|
29
|
+
onClick(event: MouseEvent<HTMLElement>): void;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The menu state for one navigation. `navRef` is the `<nav>`; a change of `resetKey` (route or breakpoint) closes
|
|
34
|
+
* every panel, as does `popstate`, Escape (focus returns to the chevron), a pointer down outside, Tab out of an item
|
|
35
|
+
* and a link click. A mouse opens a panel 100 ms after it enters an item and closes it 200 ms after it leaves, even
|
|
36
|
+
* after a click; the first click on a hover-opened chevron confirms the panel instead of closing it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function useNavigationMenu(navRef: RefObject<HTMLElement | null>, resetKey: unknown): NavigationMenu;
|
|
39
|
+
//# sourceMappingURL=use-navigation-menu.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-navigation-menu.d.ts","sourceRoot":"","sources":["../../src/navigation/use-navigation-menu.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,OAAO,EAA4C,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAGzJ,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,UAAU,CAAA;AAezE,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC5C,KAAK,IAAI,IAAI,CAAA;IACb,6DAA6D;IAC7D,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG;QACxB,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAA;QACzC,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAA;QACzC,aAAa,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAA;QACxC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI,CAAA;KAC7C,CAAA;IACD,6GAA6G;IAC7G,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG;QAC3B,OAAO,CAAC,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAAA;QACnD,SAAS,CAAC,KAAK,EAAE,aAAa,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAAA;KACzD,CAAA;IACD,2GAA2G;IAC3G,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG;QACzB,SAAS,CAAC,KAAK,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,IAAI,CAAA;QAClD,OAAO,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI,CAAA;KAC9C,CAAA;CACF;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAAE,QAAQ,EAAE,OAAO,GAAG,cAAc,CA6K1G"}
|