@brandlab-digital/conformance 3.7.0 → 3.8.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 +26 -0
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/public-copy.d.ts +7 -0
- package/dist/public-copy.d.ts.map +1 -1
- package/dist/public-copy.js +8 -3
- package/dist/public-copy.js.map +1 -1
- package/dist/ready-cli.d.ts +38 -0
- package/dist/ready-cli.d.ts.map +1 -0
- package/dist/ready-cli.js +327 -0
- package/dist/ready-cli.js.map +1 -0
- package/dist/site-system/checks.d.ts +389 -0
- package/dist/site-system/checks.d.ts.map +1 -0
- package/dist/site-system/checks.js +96 -0
- package/dist/site-system/checks.js.map +1 -0
- package/dist/site-system/config.d.ts +40 -0
- package/dist/site-system/config.d.ts.map +1 -0
- package/dist/site-system/config.js +214 -0
- package/dist/site-system/config.js.map +1 -0
- package/dist/site-system/index.d.ts +14 -0
- package/dist/site-system/index.d.ts.map +1 -0
- package/dist/site-system/index.js +14 -0
- package/dist/site-system/index.js.map +1 -0
- package/dist/site-system/journeys/access.d.ts +7 -0
- package/dist/site-system/journeys/access.d.ts.map +1 -0
- package/dist/site-system/journeys/access.js +263 -0
- package/dist/site-system/journeys/access.js.map +1 -0
- package/dist/site-system/journeys/browser.d.ts +79 -0
- package/dist/site-system/journeys/browser.d.ts.map +1 -0
- package/dist/site-system/journeys/browser.js +218 -0
- package/dist/site-system/journeys/browser.js.map +1 -0
- package/dist/site-system/journeys/conversion.d.ts +3 -0
- package/dist/site-system/journeys/conversion.d.ts.map +1 -0
- package/dist/site-system/journeys/conversion.js +152 -0
- package/dist/site-system/journeys/conversion.js.map +1 -0
- package/dist/site-system/journeys/menu.d.ts +11 -0
- package/dist/site-system/journeys/menu.d.ts.map +1 -0
- package/dist/site-system/journeys/menu.js +395 -0
- package/dist/site-system/journeys/menu.js.map +1 -0
- package/dist/site-system/journeys/page-helpers.d.ts +146 -0
- package/dist/site-system/journeys/page-helpers.d.ts.map +1 -0
- package/dist/site-system/journeys/page-helpers.js +426 -0
- package/dist/site-system/journeys/page-helpers.js.map +1 -0
- package/dist/site-system/journeys/plan.d.ts +82 -0
- package/dist/site-system/journeys/plan.d.ts.map +1 -0
- package/dist/site-system/journeys/plan.js +112 -0
- package/dist/site-system/journeys/plan.js.map +1 -0
- package/dist/site-system/journeys/shell.d.ts +24 -0
- package/dist/site-system/journeys/shell.d.ts.map +1 -0
- package/dist/site-system/journeys/shell.js +269 -0
- package/dist/site-system/journeys/shell.js.map +1 -0
- package/dist/site-system/playwright-report.d.ts +22 -0
- package/dist/site-system/playwright-report.d.ts.map +1 -0
- package/dist/site-system/playwright-report.js +131 -0
- package/dist/site-system/playwright-report.js.map +1 -0
- package/dist/site-system/records.d.ts +30 -0
- package/dist/site-system/records.d.ts.map +1 -0
- package/dist/site-system/records.js +98 -0
- package/dist/site-system/records.js.map +1 -0
- package/dist/site-system/report.d.ts +116 -0
- package/dist/site-system/report.d.ts.map +1 -0
- package/dist/site-system/report.js +366 -0
- package/dist/site-system/report.js.map +1 -0
- package/dist/site-system/rule-modules.d.ts +8 -0
- package/dist/site-system/rule-modules.d.ts.map +1 -0
- package/dist/site-system/rule-modules.js +12 -0
- package/dist/site-system/rule-modules.js.map +1 -0
- package/dist/site-system/rules/design/d4-distinctness.d.ts +36 -0
- package/dist/site-system/rules/design/d4-distinctness.d.ts.map +1 -0
- package/dist/site-system/rules/design/d4-distinctness.js +207 -0
- package/dist/site-system/rules/design/d4-distinctness.js.map +1 -0
- package/dist/site-system/rules/design/index.d.ts +17 -0
- package/dist/site-system/rules/design/index.d.ts.map +1 -0
- package/dist/site-system/rules/design/index.js +7 -0
- package/dist/site-system/rules/design/index.js.map +1 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.d.ts +31 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.js +211 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.js.map +1 -0
- package/dist/site-system/rules/enquiry/common.d.ts +37 -0
- package/dist/site-system/rules/enquiry/common.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/common.js +59 -0
- package/dist/site-system/rules/enquiry/common.js.map +1 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts +8 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.js +59 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.js.map +1 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts +22 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.js +55 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.js.map +1 -0
- package/dist/site-system/rules/enquiry/html.d.ts +53 -0
- package/dist/site-system/rules/enquiry/html.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/html.js +294 -0
- package/dist/site-system/rules/enquiry/html.js.map +1 -0
- package/dist/site-system/rules/enquiry/index.d.ts +17 -0
- package/dist/site-system/rules/enquiry/index.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/index.js +26 -0
- package/dist/site-system/rules/enquiry/index.js.map +1 -0
- package/dist/site-system/rules/enquiry/k8-sections.d.ts +17 -0
- package/dist/site-system/rules/enquiry/k8-sections.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/k8-sections.js +76 -0
- package/dist/site-system/rules/enquiry/k8-sections.js.map +1 -0
- package/dist/site-system/rules/enquiry/literal.d.ts +29 -0
- package/dist/site-system/rules/enquiry/literal.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/literal.js +189 -0
- package/dist/site-system/rules/enquiry/literal.js.map +1 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts +73 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.js +275 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.js.map +1 -0
- package/dist/site-system/rules/enquiry/t5-reviews.d.ts +23 -0
- package/dist/site-system/rules/enquiry/t5-reviews.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/t5-reviews.js +91 -0
- package/dist/site-system/rules/enquiry/t5-reviews.js.map +1 -0
- package/dist/site-system/rules/shell/banned-wording.d.ts +24 -0
- package/dist/site-system/rules/shell/banned-wording.d.ts.map +1 -0
- package/dist/site-system/rules/shell/banned-wording.js +212 -0
- package/dist/site-system/rules/shell/banned-wording.js.map +1 -0
- package/dist/site-system/rules/shell/budgets.d.ts +43 -0
- package/dist/site-system/rules/shell/budgets.d.ts.map +1 -0
- package/dist/site-system/rules/shell/budgets.js +297 -0
- package/dist/site-system/rules/shell/budgets.js.map +1 -0
- package/dist/site-system/rules/shell/cta-labels.d.ts +17 -0
- package/dist/site-system/rules/shell/cta-labels.d.ts.map +1 -0
- package/dist/site-system/rules/shell/cta-labels.js +86 -0
- package/dist/site-system/rules/shell/cta-labels.js.map +1 -0
- package/dist/site-system/rules/shell/facts.d.ts +33 -0
- package/dist/site-system/rules/shell/facts.d.ts.map +1 -0
- package/dist/site-system/rules/shell/facts.js +217 -0
- package/dist/site-system/rules/shell/facts.js.map +1 -0
- package/dist/site-system/rules/shell/headings.d.ts +5 -0
- package/dist/site-system/rules/shell/headings.d.ts.map +1 -0
- package/dist/site-system/rules/shell/headings.js +23 -0
- package/dist/site-system/rules/shell/headings.js.map +1 -0
- package/dist/site-system/rules/shell/hero-location.d.ts +4 -0
- package/dist/site-system/rules/shell/hero-location.d.ts.map +1 -0
- package/dist/site-system/rules/shell/hero-location.js +45 -0
- package/dist/site-system/rules/shell/hero-location.js.map +1 -0
- package/dist/site-system/rules/shell/html.d.ts +53 -0
- package/dist/site-system/rules/shell/html.d.ts.map +1 -0
- package/dist/site-system/rules/shell/html.js +341 -0
- package/dist/site-system/rules/shell/html.js.map +1 -0
- package/dist/site-system/rules/shell/index.d.ts +18 -0
- package/dist/site-system/rules/shell/index.d.ts.map +1 -0
- package/dist/site-system/rules/shell/index.js +65 -0
- package/dist/site-system/rules/shell/index.js.map +1 -0
- package/dist/site-system/rules/shell/licence.d.ts +4 -0
- package/dist/site-system/rules/shell/licence.d.ts.map +1 -0
- package/dist/site-system/rules/shell/licence.js +60 -0
- package/dist/site-system/rules/shell/licence.js.map +1 -0
- package/dist/site-system/rules/shell/location-wording.d.ts +13 -0
- package/dist/site-system/rules/shell/location-wording.d.ts.map +1 -0
- package/dist/site-system/rules/shell/location-wording.js +15 -0
- package/dist/site-system/rules/shell/location-wording.js.map +1 -0
- package/dist/site-system/rules/shell/navigation.d.ts +5 -0
- package/dist/site-system/rules/shell/navigation.d.ts.map +1 -0
- package/dist/site-system/rules/shell/navigation.js +272 -0
- package/dist/site-system/rules/shell/navigation.js.map +1 -0
- package/dist/site-system/rules/shell/overlays.d.ts +4 -0
- package/dist/site-system/rules/shell/overlays.d.ts.map +1 -0
- package/dist/site-system/rules/shell/overlays.js +93 -0
- package/dist/site-system/rules/shell/overlays.js.map +1 -0
- package/dist/site-system/rules/shell/page.d.ts +51 -0
- package/dist/site-system/rules/shell/page.d.ts.map +1 -0
- package/dist/site-system/rules/shell/page.js +256 -0
- package/dist/site-system/rules/shell/page.js.map +1 -0
- package/dist/site-system/rules/shell/phone.d.ts +13 -0
- package/dist/site-system/rules/shell/phone.d.ts.map +1 -0
- package/dist/site-system/rules/shell/phone.js +99 -0
- package/dist/site-system/rules/shell/phone.js.map +1 -0
- package/dist/site-system/rules/shell/proof-numbers.d.ts +7 -0
- package/dist/site-system/rules/shell/proof-numbers.d.ts.map +1 -0
- package/dist/site-system/rules/shell/proof-numbers.js +81 -0
- package/dist/site-system/rules/shell/proof-numbers.js.map +1 -0
- package/dist/site-system/rules/shell/types.d.ts +80 -0
- package/dist/site-system/rules/shell/types.d.ts.map +1 -0
- package/dist/site-system/rules/shell/types.js +17 -0
- package/dist/site-system/rules/shell/types.js.map +1 -0
- package/dist/site-system/runners.d.ts +37 -0
- package/dist/site-system/runners.d.ts.map +1 -0
- package/dist/site-system/runners.js +48 -0
- package/dist/site-system/runners.js.map +1 -0
- package/dist/site-system/samples.d.ts +43 -0
- package/dist/site-system/samples.d.ts.map +1 -0
- package/dist/site-system/samples.js +99 -0
- package/dist/site-system/samples.js.map +1 -0
- package/dist/site-system/steps.d.ts +66 -0
- package/dist/site-system/steps.d.ts.map +1 -0
- package/dist/site-system/steps.js +45 -0
- package/dist/site-system/steps.js.map +1 -0
- package/dist/site-system/streamed-html.d.ts +9 -0
- package/dist/site-system/streamed-html.d.ts.map +1 -0
- package/dist/site-system/streamed-html.js +43 -0
- package/dist/site-system/streamed-html.js.map +1 -0
- package/dist/site-system-e2e.d.ts +6 -0
- package/dist/site-system-e2e.d.ts.map +1 -0
- package/dist/site-system-e2e.js +54 -0
- package/dist/site-system-e2e.js.map +1 -0
- package/package.json +22 -5
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions the site-system journeys run inside the page under test. Each one is self-contained: it uses DOM globals
|
|
3
|
+
* and the other helpers in this file only, because `pageHelpersSource()` injects their source into every document
|
|
4
|
+
* (as `window.__brandlabSiteSystem`, through Playwright's init script, which a Content Security Policy does not
|
|
5
|
+
* block). The pure parts (colour, contrast, placement) are unit-tested in Node.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The placements `phone_link_click` accepts: site-next's PHONE_CALL_PLACEMENTS (conformance cannot depend on
|
|
9
|
+
* site-next; a test reads the site-next source and compares). Injected into the page with the helpers.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SITE_SYSTEM_PHONE_PLACEMENTS: readonly string[];
|
|
12
|
+
export type Rgba = [number, number, number, number];
|
|
13
|
+
/** What `watchDisclosure` has seen, in `performance.now()` milliseconds. */
|
|
14
|
+
export interface DisclosureWatch {
|
|
15
|
+
enter: number | null;
|
|
16
|
+
leave: number | null;
|
|
17
|
+
opened: number | null;
|
|
18
|
+
closed: number | null;
|
|
19
|
+
open: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether an element is rendered: connected, with a box, not `visibility: hidden`, not fully transparent and not
|
|
23
|
+
* inside a closed `<details>` (outside its summary). Chromium gives the content of a closed `<details>` a box, so
|
|
24
|
+
* the box alone is not enough; `checkVisibility()` settles it where the browser has it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isRendered(element: Element | null | undefined): boolean;
|
|
27
|
+
/** A short, readable name for an element in a failure message. */
|
|
28
|
+
export declare function describeElement(element: Element): string;
|
|
29
|
+
/** The accessible name, approximately: aria-label, aria-labelledby, the rendered text, then the title. */
|
|
30
|
+
export declare function accessibleName(element: Element): string;
|
|
31
|
+
/**
|
|
32
|
+
* Where a telephone link reports its tap, exactly as site-next's shared listener resolves it: its own valid
|
|
33
|
+
* `data-phone-location`, then a `<header>` or `<footer>` ancestor, then the nearest ancestor with a valid value,
|
|
34
|
+
* otherwise `page`. `invalid` is the nearest declared value when it is not an allowed placement (the listener then
|
|
35
|
+
* reports the link somewhere else, silently).
|
|
36
|
+
*/
|
|
37
|
+
export declare function phonePlacement(link: Element): {
|
|
38
|
+
placement: string;
|
|
39
|
+
invalid: string | null;
|
|
40
|
+
};
|
|
41
|
+
/** Parse a computed colour. `rgb()`/`rgba()` directly; any other syntax (oklch, lab, color()) through a canvas pixel. */
|
|
42
|
+
export declare function parseColour(value: string): Rgba | null;
|
|
43
|
+
/** `top` painted over an opaque `bottom`. */
|
|
44
|
+
export declare function compositeColour(top: Rgba, bottom: Rgba): Rgba;
|
|
45
|
+
/** The WCAG contrast ratio of two opaque colours. */
|
|
46
|
+
export declare function contrastRatio(first: Rgba, second: Rgba): number;
|
|
47
|
+
/**
|
|
48
|
+
* The opaque colour behind an element, from its own and its ancestors' background colours over a white canvas.
|
|
49
|
+
* `null` when a background image (a photo or gradient) is in the way: the contrast cannot be computed there.
|
|
50
|
+
*/
|
|
51
|
+
export declare function backgroundBehind(element: Element): Rgba | null;
|
|
52
|
+
/**
|
|
53
|
+
* Non-text contrast (WCAG 1.4.11) of icon-only controls inside `root`: a link or button with no visible text whose
|
|
54
|
+
* icon is an SVG must reach 3:1 against what is behind it. Returns one line per failure; controls over a photo are
|
|
55
|
+
* counted in `unmeasured`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function iconContrastProblems(root: Element): {
|
|
58
|
+
problems: string[];
|
|
59
|
+
checked: number;
|
|
60
|
+
unmeasured: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The phone call bar: the shared `MobileCallBar`, or any fixed or sticky element along the bottom of the screen that
|
|
64
|
+
* holds a visible telephone link (outside the header and dialogs). `null` when none is on screen.
|
|
65
|
+
*/
|
|
66
|
+
export declare function findCallBar(): Element | null;
|
|
67
|
+
/** Visible quote actions in `root`: links or buttons named like the quote action, or the shared quote hooks. */
|
|
68
|
+
export declare function quoteActions(root: Element, name: {
|
|
69
|
+
source: string;
|
|
70
|
+
flags: string;
|
|
71
|
+
}): Element[];
|
|
72
|
+
/** The order of the first visible phone link and quote action in `root`. */
|
|
73
|
+
export declare function actionOrder(root: Element, name: {
|
|
74
|
+
source: string;
|
|
75
|
+
flags: string;
|
|
76
|
+
}): 'phone-first' | 'quote-first' | 'incomplete';
|
|
77
|
+
/**
|
|
78
|
+
* The hero: the shared hero section, else the smallest block around the page's visible H1 that holds a phone link
|
|
79
|
+
* and a quote action, never above the H1's own section and never `<main>` (without a section, never a block holding
|
|
80
|
+
* an H2). When none holds both, the H1's section (or its parent): its order then reads "incomplete".
|
|
81
|
+
*/
|
|
82
|
+
export declare function heroRegion(name: {
|
|
83
|
+
source: string;
|
|
84
|
+
flags: string;
|
|
85
|
+
}): Element | null;
|
|
86
|
+
/** A point in the page that is not on an interactive element, a menu or anything in `avoid`. */
|
|
87
|
+
export declare function outsidePoint(avoid: Element[]): {
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
} | null;
|
|
91
|
+
/** Tag an element so Playwright and axe can find it by `[data-brandlab-journey~="<name>"]`. */
|
|
92
|
+
export declare function markElement(element: Element, name: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* Watch one disclosure: when the pointer enters and leaves its item (and panel), and when the panel it controls
|
|
95
|
+
* becomes rendered or hidden, timed with `performance.now()`. Attribute and tree changes are sampled at once; style
|
|
96
|
+
* changes without a DOM change (CSS `:hover`) every animation frame.
|
|
97
|
+
*/
|
|
98
|
+
export declare function watchDisclosure(trigger: Element, panelId: string): void;
|
|
99
|
+
/** The current state of `watchDisclosure`'s watch. */
|
|
100
|
+
export declare function watchState(): DisclosureWatch | null;
|
|
101
|
+
/**
|
|
102
|
+
* After a Tab press: is the focused element fully hidden under the sticky header or the call bar? Samples nine points
|
|
103
|
+
* across the visible part of its box; it is hidden when the header or call bar is on top at every one. `repeat` is
|
|
104
|
+
* true once focus comes back to an element it has already visited.
|
|
105
|
+
*/
|
|
106
|
+
export declare function focusStep(): {
|
|
107
|
+
done: boolean;
|
|
108
|
+
repeat: boolean;
|
|
109
|
+
obscured: string | null;
|
|
110
|
+
covered: 'header' | 'call bar' | null;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* `focusStep` one animation frame after the focus moved (capped at 50 ms, since a background page may not paint), so
|
|
114
|
+
* a sticky bar that hides on its observer's next frame is measured after it moved, not mid-transition.
|
|
115
|
+
*/
|
|
116
|
+
export declare function focusStepAfterFrame(): Promise<ReturnType<typeof focusStep>>;
|
|
117
|
+
/** Elements a Tab press can reach, roughly: enough to bound the K5 walk. */
|
|
118
|
+
export declare function tabbableCount(): number;
|
|
119
|
+
declare const PAGE_HELPERS: {
|
|
120
|
+
isRendered: typeof isRendered;
|
|
121
|
+
describeElement: typeof describeElement;
|
|
122
|
+
accessibleName: typeof accessibleName;
|
|
123
|
+
phonePlacement: typeof phonePlacement;
|
|
124
|
+
parseColour: typeof parseColour;
|
|
125
|
+
compositeColour: typeof compositeColour;
|
|
126
|
+
contrastRatio: typeof contrastRatio;
|
|
127
|
+
backgroundBehind: typeof backgroundBehind;
|
|
128
|
+
iconContrastProblems: typeof iconContrastProblems;
|
|
129
|
+
findCallBar: typeof findCallBar;
|
|
130
|
+
quoteActions: typeof quoteActions;
|
|
131
|
+
actionOrder: typeof actionOrder;
|
|
132
|
+
heroRegion: typeof heroRegion;
|
|
133
|
+
outsidePoint: typeof outsidePoint;
|
|
134
|
+
markElement: typeof markElement;
|
|
135
|
+
watchDisclosure: typeof watchDisclosure;
|
|
136
|
+
watchState: typeof watchState;
|
|
137
|
+
focusStep: typeof focusStep;
|
|
138
|
+
focusStepAfterFrame: typeof focusStepAfterFrame;
|
|
139
|
+
tabbableCount: typeof tabbableCount;
|
|
140
|
+
};
|
|
141
|
+
/** `window.__brandlabSiteSystem` in a page the journeys opened. */
|
|
142
|
+
export type SiteSystemPageHelpers = typeof PAGE_HELPERS;
|
|
143
|
+
/** The init script that defines `window.__brandlabSiteSystem` in every document of the page. */
|
|
144
|
+
export declare function pageHelpersSource(): string;
|
|
145
|
+
export {};
|
|
146
|
+
//# sourceMappingURL=page-helpers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-helpers.d.ts","sourceRoot":"","sources":["../../../src/site-system/journeys/page-helpers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions the site-system journeys run inside the page under test. Each one is self-contained: it uses DOM globals
|
|
3
|
+
* and the other helpers in this file only, because `pageHelpersSource()` injects their source into every document
|
|
4
|
+
* (as `window.__brandlabSiteSystem`, through Playwright's init script, which a Content Security Policy does not
|
|
5
|
+
* block). The pure parts (colour, contrast, placement) are unit-tested in Node.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The placements `phone_link_click` accepts: site-next's PHONE_CALL_PLACEMENTS (conformance cannot depend on
|
|
9
|
+
* site-next; a test reads the site-next source and compares). Injected into the page with the helpers.
|
|
10
|
+
*/
|
|
11
|
+
export const SITE_SYSTEM_PHONE_PLACEMENTS = Object.freeze(['header', 'footer', 'hero', 'cta', 'fab', 'contact', 'location', 'page']);
|
|
12
|
+
/**
|
|
13
|
+
* Whether an element is rendered: connected, with a box, not `visibility: hidden`, not fully transparent and not
|
|
14
|
+
* inside a closed `<details>` (outside its summary). Chromium gives the content of a closed `<details>` a box, so
|
|
15
|
+
* the box alone is not enough; `checkVisibility()` settles it where the browser has it.
|
|
16
|
+
*/
|
|
17
|
+
export function isRendered(element) {
|
|
18
|
+
if (!element || !element.isConnected)
|
|
19
|
+
return false;
|
|
20
|
+
for (let details = element.closest('details:not([open])'); details; details = details.parentElement?.closest('details:not([open])') ?? null) {
|
|
21
|
+
const summary = Array.from(details.children).find((child) => child.tagName === 'SUMMARY');
|
|
22
|
+
if (!summary || !summary.contains(element))
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (typeof element.checkVisibility === 'function' && !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }))
|
|
26
|
+
return false;
|
|
27
|
+
const rect = element.getBoundingClientRect();
|
|
28
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
29
|
+
return false;
|
|
30
|
+
if (getComputedStyle(element).visibility !== 'visible')
|
|
31
|
+
return false;
|
|
32
|
+
for (let node = element; node; node = node.parentElement) {
|
|
33
|
+
if (getComputedStyle(node).opacity === '0')
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/** A short, readable name for an element in a failure message. */
|
|
39
|
+
export function describeElement(element) {
|
|
40
|
+
const tag = element.tagName.toLowerCase();
|
|
41
|
+
const label = element.getAttribute('aria-label') ?? element.innerText ?? element.textContent ?? '';
|
|
42
|
+
const text = label.replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
43
|
+
const href = element.getAttribute('href');
|
|
44
|
+
return `${tag}${text ? ` "${text}"` : ''}${href ? ` (${href})` : ''}`;
|
|
45
|
+
}
|
|
46
|
+
/** The accessible name, approximately: aria-label, aria-labelledby, the rendered text, then the title. */
|
|
47
|
+
export function accessibleName(element) {
|
|
48
|
+
const label = element.getAttribute('aria-label');
|
|
49
|
+
if (label && label.trim())
|
|
50
|
+
return label.trim();
|
|
51
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
52
|
+
if (labelledBy) {
|
|
53
|
+
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim();
|
|
54
|
+
if (text)
|
|
55
|
+
return text.replace(/\s+/g, ' ');
|
|
56
|
+
}
|
|
57
|
+
const text = (element.innerText ?? element.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
58
|
+
return text || (element.getAttribute('title') ?? '').trim();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where a telephone link reports its tap, exactly as site-next's shared listener resolves it: its own valid
|
|
62
|
+
* `data-phone-location`, then a `<header>` or `<footer>` ancestor, then the nearest ancestor with a valid value,
|
|
63
|
+
* otherwise `page`. `invalid` is the nearest declared value when it is not an allowed placement (the listener then
|
|
64
|
+
* reports the link somewhere else, silently).
|
|
65
|
+
*/
|
|
66
|
+
export function phonePlacement(link) {
|
|
67
|
+
const valid = (node) => {
|
|
68
|
+
const value = node.getAttribute('data-phone-location')?.trim().toLowerCase();
|
|
69
|
+
return value && SITE_SYSTEM_PHONE_PLACEMENTS.includes(value) ? value : undefined;
|
|
70
|
+
};
|
|
71
|
+
const nearest = link.closest('[data-phone-location]');
|
|
72
|
+
const invalid = nearest && !valid(nearest) ? nearest.getAttribute('data-phone-location') : null;
|
|
73
|
+
const own = valid(link);
|
|
74
|
+
if (own)
|
|
75
|
+
return { placement: own, invalid };
|
|
76
|
+
if (link.closest('header'))
|
|
77
|
+
return { placement: 'header', invalid };
|
|
78
|
+
if (link.closest('footer'))
|
|
79
|
+
return { placement: 'footer', invalid };
|
|
80
|
+
for (let node = link.parentElement?.closest('[data-phone-location]') ?? null; node; node = node.parentElement?.closest('[data-phone-location]') ?? null) {
|
|
81
|
+
const value = valid(node);
|
|
82
|
+
if (value)
|
|
83
|
+
return { placement: value, invalid };
|
|
84
|
+
}
|
|
85
|
+
return { placement: 'page', invalid };
|
|
86
|
+
}
|
|
87
|
+
/** Parse a computed colour. `rgb()`/`rgba()` directly; any other syntax (oklch, lab, color()) through a canvas pixel. */
|
|
88
|
+
export function parseColour(value) {
|
|
89
|
+
const text = value.trim().toLowerCase();
|
|
90
|
+
if (text === 'transparent')
|
|
91
|
+
return [0, 0, 0, 0];
|
|
92
|
+
const match = /^rgba?\(([^)]+)\)$/.exec(text);
|
|
93
|
+
if (match) {
|
|
94
|
+
const parts = match[1].split(/[\s,/]+/).filter(Boolean);
|
|
95
|
+
if (parts.length < 3)
|
|
96
|
+
return null;
|
|
97
|
+
const channel = (part) => (part.endsWith('%') ? (Number.parseFloat(part) / 100) * 255 : Number.parseFloat(part));
|
|
98
|
+
const alphaPart = parts[3];
|
|
99
|
+
const alpha = alphaPart === undefined ? 1 : alphaPart.endsWith('%') ? Number.parseFloat(alphaPart) / 100 : Number.parseFloat(alphaPart);
|
|
100
|
+
const rgba = [channel(parts[0]), channel(parts[1]), channel(parts[2]), alpha];
|
|
101
|
+
return rgba.every((part) => Number.isFinite(part)) ? rgba : null;
|
|
102
|
+
}
|
|
103
|
+
if (typeof document === 'undefined')
|
|
104
|
+
return null;
|
|
105
|
+
const canvas = document.createElement('canvas');
|
|
106
|
+
canvas.width = 1;
|
|
107
|
+
canvas.height = 1;
|
|
108
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
109
|
+
if (!context)
|
|
110
|
+
return null;
|
|
111
|
+
context.clearRect(0, 0, 1, 1);
|
|
112
|
+
context.fillStyle = value;
|
|
113
|
+
context.fillRect(0, 0, 1, 1);
|
|
114
|
+
const [red = 0, green = 0, blue = 0, alpha = 0] = Array.from(context.getImageData(0, 0, 1, 1).data);
|
|
115
|
+
return [red, green, blue, alpha / 255];
|
|
116
|
+
}
|
|
117
|
+
/** `top` painted over an opaque `bottom`. */
|
|
118
|
+
export function compositeColour(top, bottom) {
|
|
119
|
+
const alpha = top[3];
|
|
120
|
+
return [
|
|
121
|
+
top[0] * alpha + bottom[0] * (1 - alpha),
|
|
122
|
+
top[1] * alpha + bottom[1] * (1 - alpha),
|
|
123
|
+
top[2] * alpha + bottom[2] * (1 - alpha),
|
|
124
|
+
1,
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
/** The WCAG contrast ratio of two opaque colours. */
|
|
128
|
+
export function contrastRatio(first, second) {
|
|
129
|
+
const luminance = (colour) => {
|
|
130
|
+
const [red, green, blue] = colour.slice(0, 3).map((channel) => {
|
|
131
|
+
const value = channel / 255;
|
|
132
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
133
|
+
});
|
|
134
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
135
|
+
};
|
|
136
|
+
const [light, dark] = [luminance(first), luminance(second)].sort((left, right) => right - left);
|
|
137
|
+
return (light + 0.05) / (dark + 0.05);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The opaque colour behind an element, from its own and its ancestors' background colours over a white canvas.
|
|
141
|
+
* `null` when a background image (a photo or gradient) is in the way: the contrast cannot be computed there.
|
|
142
|
+
*/
|
|
143
|
+
export function backgroundBehind(element) {
|
|
144
|
+
const layers = [];
|
|
145
|
+
for (let node = element; node; node = node.parentElement) {
|
|
146
|
+
const style = getComputedStyle(node);
|
|
147
|
+
if (style.backgroundImage && style.backgroundImage !== 'none')
|
|
148
|
+
return null;
|
|
149
|
+
const colour = parseColour(style.backgroundColor);
|
|
150
|
+
if (colour && colour[3] > 0) {
|
|
151
|
+
layers.push(colour);
|
|
152
|
+
if (colour[3] >= 1)
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return layers.reverse().reduce((bottom, top) => compositeColour(top, bottom), [255, 255, 255, 1]);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Non-text contrast (WCAG 1.4.11) of icon-only controls inside `root`: a link or button with no visible text whose
|
|
160
|
+
* icon is an SVG must reach 3:1 against what is behind it. Returns one line per failure; controls over a photo are
|
|
161
|
+
* counted in `unmeasured`.
|
|
162
|
+
*/
|
|
163
|
+
export function iconContrastProblems(root) {
|
|
164
|
+
const problems = [];
|
|
165
|
+
let checked = 0;
|
|
166
|
+
let unmeasured = 0;
|
|
167
|
+
const paintOf = (svg) => {
|
|
168
|
+
for (const shape of Array.from(svg.querySelectorAll('path, circle, rect, ellipse, line, polyline, polygon, text, use'))) {
|
|
169
|
+
if (!isRendered(shape))
|
|
170
|
+
continue;
|
|
171
|
+
const style = getComputedStyle(shape);
|
|
172
|
+
for (const [paint, opacity] of [[style.fill, style.fillOpacity], [style.stroke, style.strokeOpacity]]) {
|
|
173
|
+
if (!paint || paint === 'none' || paint.startsWith('url('))
|
|
174
|
+
continue;
|
|
175
|
+
const colour = parseColour(paint);
|
|
176
|
+
const alpha = colour ? colour[3] * Number(opacity || 1) * Number(style.opacity || 1) : 0;
|
|
177
|
+
if (colour && alpha > 0)
|
|
178
|
+
return [colour[0], colour[1], colour[2], alpha];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
};
|
|
183
|
+
for (const control of Array.from(root.querySelectorAll('a[href], button'))) {
|
|
184
|
+
if (!isRendered(control))
|
|
185
|
+
continue;
|
|
186
|
+
if ((control.innerText ?? '').trim())
|
|
187
|
+
continue;
|
|
188
|
+
const svg = control.querySelector('svg');
|
|
189
|
+
if (!svg || !isRendered(svg))
|
|
190
|
+
continue;
|
|
191
|
+
const paint = paintOf(svg);
|
|
192
|
+
if (!paint)
|
|
193
|
+
continue;
|
|
194
|
+
const background = backgroundBehind(svg);
|
|
195
|
+
if (!background) {
|
|
196
|
+
unmeasured += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
checked += 1;
|
|
200
|
+
const ratio = contrastRatio(compositeColour(paint, background), background);
|
|
201
|
+
if (ratio < 3)
|
|
202
|
+
problems.push(`${describeElement(control)}: its icon is ${ratio.toFixed(2)}:1 against its background (needs 3:1)`);
|
|
203
|
+
}
|
|
204
|
+
return { problems, checked, unmeasured };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The phone call bar: the shared `MobileCallBar`, or any fixed or sticky element along the bottom of the screen that
|
|
208
|
+
* holds a visible telephone link (outside the header and dialogs). `null` when none is on screen.
|
|
209
|
+
*/
|
|
210
|
+
export function findCallBar() {
|
|
211
|
+
const tagged = Array.from(document.querySelectorAll('[data-bl="call-bar"], [data-bl="callbar"], [data-bl="mobile-call-bar"]')).find(isRendered);
|
|
212
|
+
if (tagged)
|
|
213
|
+
return tagged;
|
|
214
|
+
const height = window.innerHeight;
|
|
215
|
+
for (const element of Array.from(document.body.querySelectorAll('*'))) {
|
|
216
|
+
const position = getComputedStyle(element).position;
|
|
217
|
+
if (position !== 'fixed' && position !== 'sticky')
|
|
218
|
+
continue;
|
|
219
|
+
if (element.closest('header, dialog, [role="dialog"]'))
|
|
220
|
+
continue;
|
|
221
|
+
const rect = element.getBoundingClientRect();
|
|
222
|
+
if (rect.height <= 0 || rect.bottom < height - 4 || rect.top < height / 2)
|
|
223
|
+
continue;
|
|
224
|
+
if (!isRendered(element))
|
|
225
|
+
continue;
|
|
226
|
+
if (Array.from(element.querySelectorAll('a[href^="tel:"]')).some(isRendered))
|
|
227
|
+
return element;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/** Visible quote actions in `root`: links or buttons named like the quote action, or the shared quote hooks. */
|
|
232
|
+
export function quoteActions(root, name) {
|
|
233
|
+
const pattern = new RegExp(name.source, name.flags);
|
|
234
|
+
return Array.from(root.querySelectorAll('a[href], button')).filter((element) => {
|
|
235
|
+
if (!isRendered(element) || element.getAttribute('href')?.startsWith('tel:'))
|
|
236
|
+
return false;
|
|
237
|
+
if (element.matches('[data-bl="quote-button"], [data-bl-cta^="quote"], [data-lead-cta^="quote"]'))
|
|
238
|
+
return true;
|
|
239
|
+
return pattern.test(accessibleName(element));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** The order of the first visible phone link and quote action in `root`. */
|
|
243
|
+
export function actionOrder(root, name) {
|
|
244
|
+
const phone = Array.from(root.querySelectorAll('a[href^="tel:"]')).find(isRendered);
|
|
245
|
+
const quote = quoteActions(root, name)[0];
|
|
246
|
+
if (!phone || !quote)
|
|
247
|
+
return 'incomplete';
|
|
248
|
+
return phone.compareDocumentPosition(quote) & Node.DOCUMENT_POSITION_FOLLOWING ? 'phone-first' : 'quote-first';
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The hero: the shared hero section, else the smallest block around the page's visible H1 that holds a phone link
|
|
252
|
+
* and a quote action, never above the H1's own section and never `<main>` (without a section, never a block holding
|
|
253
|
+
* an H2). When none holds both, the H1's section (or its parent): its order then reads "incomplete".
|
|
254
|
+
*/
|
|
255
|
+
export function heroRegion(name) {
|
|
256
|
+
const heading = Array.from(document.querySelectorAll('h1')).find(isRendered);
|
|
257
|
+
if (!heading)
|
|
258
|
+
return null;
|
|
259
|
+
const tagged = heading.closest('[data-bl-section="hero"]');
|
|
260
|
+
if (tagged)
|
|
261
|
+
return tagged;
|
|
262
|
+
const section = heading.closest('section');
|
|
263
|
+
const fallback = section ?? heading.parentElement;
|
|
264
|
+
for (let node = heading.parentElement; node && node !== document.body && node.tagName !== 'MAIN'; node = node.parentElement) {
|
|
265
|
+
if (!section && node.querySelector('h2'))
|
|
266
|
+
break;
|
|
267
|
+
if (Array.from(node.querySelectorAll('a[href^="tel:"]')).some(isRendered) && quoteActions(node, name).length > 0)
|
|
268
|
+
return node;
|
|
269
|
+
if (node === section)
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
return fallback && fallback.tagName !== 'MAIN' ? fallback : heading;
|
|
273
|
+
}
|
|
274
|
+
/** A point in the page that is not on an interactive element, a menu or anything in `avoid`. */
|
|
275
|
+
export function outsidePoint(avoid) {
|
|
276
|
+
const interactive = 'a[href], button, input, select, textarea, label, summary, details, iframe, video, audio, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [tabindex], [contenteditable=""], [contenteditable="true"]';
|
|
277
|
+
for (let row = 9; row >= 3; row -= 1) {
|
|
278
|
+
for (let column = 1; column <= 9; column += 1) {
|
|
279
|
+
const x = Math.round((window.innerWidth * column) / 10);
|
|
280
|
+
const y = Math.round((window.innerHeight * row) / 10);
|
|
281
|
+
const element = document.elementFromPoint(x, y);
|
|
282
|
+
if (!element || avoid.some((region) => region.contains(element)))
|
|
283
|
+
continue;
|
|
284
|
+
if (element.closest(interactive) || element.closest('header, nav, dialog, [role="dialog"]'))
|
|
285
|
+
continue;
|
|
286
|
+
return { x, y };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/** Tag an element so Playwright and axe can find it by `[data-brandlab-journey~="<name>"]`. */
|
|
292
|
+
export function markElement(element, name) {
|
|
293
|
+
const names = new Set((element.getAttribute('data-brandlab-journey') ?? '').split(/\s+/).filter(Boolean));
|
|
294
|
+
names.add(name);
|
|
295
|
+
element.setAttribute('data-brandlab-journey', [...names].join(' '));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Watch one disclosure: when the pointer enters and leaves its item (and panel), and when the panel it controls
|
|
299
|
+
* becomes rendered or hidden, timed with `performance.now()`. Attribute and tree changes are sampled at once; style
|
|
300
|
+
* changes without a DOM change (CSS `:hover`) every animation frame.
|
|
301
|
+
*/
|
|
302
|
+
export function watchDisclosure(trigger, panelId) {
|
|
303
|
+
const item = trigger.closest('li') ?? trigger.parentElement ?? trigger;
|
|
304
|
+
const panel = () => document.getElementById(panelId);
|
|
305
|
+
const inside = (node) => node instanceof Node && (item.contains(node) || Boolean(panel()?.contains(node)));
|
|
306
|
+
const state = { enter: null, leave: null, opened: null, closed: null, open: isRendered(panel()) };
|
|
307
|
+
const sample = () => {
|
|
308
|
+
const open = isRendered(panel());
|
|
309
|
+
if (open === state.open)
|
|
310
|
+
return;
|
|
311
|
+
state.open = open;
|
|
312
|
+
if (open && state.opened === null)
|
|
313
|
+
state.opened = performance.now();
|
|
314
|
+
if (!open && state.closed === null)
|
|
315
|
+
state.closed = performance.now();
|
|
316
|
+
};
|
|
317
|
+
const onOver = (event) => {
|
|
318
|
+
if (state.enter === null && inside(event.target))
|
|
319
|
+
state.enter = performance.now();
|
|
320
|
+
};
|
|
321
|
+
const onOut = (event) => {
|
|
322
|
+
if (state.leave === null && inside(event.target) && !inside(event.relatedTarget))
|
|
323
|
+
state.leave = performance.now();
|
|
324
|
+
};
|
|
325
|
+
const observer = new MutationObserver(sample);
|
|
326
|
+
observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true });
|
|
327
|
+
let frame = 0;
|
|
328
|
+
const loop = () => {
|
|
329
|
+
sample();
|
|
330
|
+
frame = requestAnimationFrame(loop);
|
|
331
|
+
};
|
|
332
|
+
loop();
|
|
333
|
+
document.addEventListener('pointerover', onOver, true);
|
|
334
|
+
document.addEventListener('pointerout', onOut, true);
|
|
335
|
+
const previous = window.__brandlabSiteSystemWatch;
|
|
336
|
+
previous?.stop();
|
|
337
|
+
window.__brandlabSiteSystemWatch = {
|
|
338
|
+
state,
|
|
339
|
+
stop() {
|
|
340
|
+
observer.disconnect();
|
|
341
|
+
cancelAnimationFrame(frame);
|
|
342
|
+
document.removeEventListener('pointerover', onOver, true);
|
|
343
|
+
document.removeEventListener('pointerout', onOut, true);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** The current state of `watchDisclosure`'s watch. */
|
|
348
|
+
export function watchState() {
|
|
349
|
+
const watch = window.__brandlabSiteSystemWatch;
|
|
350
|
+
return watch ? { ...watch.state } : null;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* After a Tab press: is the focused element fully hidden under the sticky header or the call bar? Samples nine points
|
|
354
|
+
* across the visible part of its box; it is hidden when the header or call bar is on top at every one. `repeat` is
|
|
355
|
+
* true once focus comes back to an element it has already visited.
|
|
356
|
+
*/
|
|
357
|
+
export function focusStep() {
|
|
358
|
+
const element = document.activeElement;
|
|
359
|
+
const store = window;
|
|
360
|
+
store.__brandlabSiteSystemFocus ??= new WeakSet();
|
|
361
|
+
if (!element || element === document.body || element === document.documentElement)
|
|
362
|
+
return { done: true, repeat: false, obscured: null, covered: null };
|
|
363
|
+
const repeat = store.__brandlabSiteSystemFocus.has(element);
|
|
364
|
+
store.__brandlabSiteSystemFocus.add(element);
|
|
365
|
+
const header = document.querySelector('[data-brandlab-journey~="header"]')
|
|
366
|
+
?? Array.from(document.querySelectorAll('[data-bl="header"], header')).find((candidate) => isRendered(candidate) && !candidate.closest('main, article, section, aside, footer'))
|
|
367
|
+
?? null;
|
|
368
|
+
const callBar = findCallBar();
|
|
369
|
+
if (header?.contains(element) || callBar?.contains(element))
|
|
370
|
+
return { done: false, repeat, obscured: null, covered: null };
|
|
371
|
+
const rect = element.getBoundingClientRect();
|
|
372
|
+
const left = Math.max(rect.left, 0);
|
|
373
|
+
const right = Math.min(rect.right, window.innerWidth);
|
|
374
|
+
const top = Math.max(rect.top, 0);
|
|
375
|
+
const bottom = Math.min(rect.bottom, window.innerHeight);
|
|
376
|
+
if (right - left < 1 || bottom - top < 1)
|
|
377
|
+
return { done: false, repeat, obscured: null, covered: null };
|
|
378
|
+
let coveredBy = null;
|
|
379
|
+
for (const fx of [0.15, 0.5, 0.85]) {
|
|
380
|
+
for (const fy of [0.15, 0.5, 0.85]) {
|
|
381
|
+
const hit = document.elementFromPoint(left + (right - left) * fx, top + (bottom - top) * fy);
|
|
382
|
+
if (!hit || element.contains(hit))
|
|
383
|
+
return { done: false, repeat, obscured: null, covered: null };
|
|
384
|
+
if (header?.contains(hit))
|
|
385
|
+
coveredBy ??= 'header';
|
|
386
|
+
else if (callBar?.contains(hit))
|
|
387
|
+
coveredBy ??= 'call bar';
|
|
388
|
+
else
|
|
389
|
+
return { done: false, repeat, obscured: null, covered: null };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { done: false, repeat, obscured: describeElement(element), covered: coveredBy };
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* `focusStep` one animation frame after the focus moved (capped at 50 ms, since a background page may not paint), so
|
|
396
|
+
* a sticky bar that hides on its observer's next frame is measured after it moved, not mid-transition.
|
|
397
|
+
*/
|
|
398
|
+
export function focusStepAfterFrame() {
|
|
399
|
+
return new Promise((resolve) => {
|
|
400
|
+
let settled = false;
|
|
401
|
+
const finish = () => {
|
|
402
|
+
if (settled)
|
|
403
|
+
return;
|
|
404
|
+
settled = true;
|
|
405
|
+
resolve(focusStep());
|
|
406
|
+
};
|
|
407
|
+
requestAnimationFrame(() => finish());
|
|
408
|
+
setTimeout(finish, 50);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/** Elements a Tab press can reach, roughly: enough to bound the K5 walk. */
|
|
412
|
+
export function tabbableCount() {
|
|
413
|
+
const selector = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), summary, iframe, [tabindex]:not([tabindex="-1"])';
|
|
414
|
+
return Array.from(document.querySelectorAll(selector)).filter(isRendered).length;
|
|
415
|
+
}
|
|
416
|
+
const PAGE_HELPERS = {
|
|
417
|
+
isRendered, describeElement, accessibleName, phonePlacement, parseColour, compositeColour, contrastRatio, backgroundBehind,
|
|
418
|
+
iconContrastProblems, findCallBar, quoteActions, actionOrder, heroRegion, outsidePoint, markElement, watchDisclosure,
|
|
419
|
+
watchState, focusStep, focusStepAfterFrame, tabbableCount,
|
|
420
|
+
};
|
|
421
|
+
/** The init script that defines `window.__brandlabSiteSystem` in every document of the page. */
|
|
422
|
+
export function pageHelpersSource() {
|
|
423
|
+
const names = Object.keys(PAGE_HELPERS);
|
|
424
|
+
return `(() => {\nconst SITE_SYSTEM_PHONE_PLACEMENTS = ${JSON.stringify(SITE_SYSTEM_PHONE_PLACEMENTS)};\n${Object.values(PAGE_HELPERS).map((helper) => helper.toString()).join('\n')}\nwindow.__brandlabSiteSystem = { ${names.join(', ')} };\n})();`;
|
|
425
|
+
}
|
|
426
|
+
//# sourceMappingURL=page-helpers.js.map
|