@getvitops/astro 2.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +337 -0
- package/README.md +2 -2
- package/dist/index.d.mts +75 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +73 -26
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +45 -0
- package/dist/routes/conversion.d.mts.map +1 -0
- package/dist/routes/conversion.mjs +95 -0
- package/dist/routes/conversion.mjs.map +1 -0
- package/package.json +23 -6
- package/src/analytics.ts +1 -1
- package/src/components/Analytics.astro +2 -2
- package/src/components/CookieConsent.astro +25 -17
- package/src/components/Details.astro +1 -1
- package/src/components/Drawer.astro +1 -1
- package/src/components/Head.astro +45 -7
- package/src/components/Icon.astro +3 -3
- package/src/components/NavShell.astro +123 -0
- package/src/components/NavShellToggle.astro +45 -0
- package/src/components/Popover.astro +1 -1
- package/src/components/Seo.astro +2 -2
- package/src/components/Tracking.astro +43 -0
- package/src/components/Tree.astro +82 -0
- package/src/components/TreeNode.astro +63 -0
- package/src/components/WebComponentLoader.astro +1 -1
- package/src/components/tree.ts +19 -0
- package/src/scripts/tracking.ts +147 -0
- package/src/seo.ts +2 -2
- package/src/tracking.ts +82 -0
- package/virtual.d.ts +5 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { EmailBinding, NotifyContext, notify } from "@getvitops/utils/notify";
|
|
2
|
+
|
|
3
|
+
//#region src/routes/conversion.d.ts
|
|
4
|
+
/** The subset of Astro's `APIContext` this needs — typed structurally so the
|
|
5
|
+
* factory does not pin an Astro version. */
|
|
6
|
+
interface ConversionRequestContext {
|
|
7
|
+
request: Request;
|
|
8
|
+
locals?: unknown;
|
|
9
|
+
}
|
|
10
|
+
interface ConversionRouteOptions {
|
|
11
|
+
/** Where notifications go. Build it from your site config. */
|
|
12
|
+
context: NotifyContext;
|
|
13
|
+
/**
|
|
14
|
+
* The Workers `send_email` binding for this request, e.g.
|
|
15
|
+
* `(locals) => locals.runtime.env.EMAIL`. Omit it in dev and the notification
|
|
16
|
+
* is printed instead of sent.
|
|
17
|
+
*/
|
|
18
|
+
binding?: (locals: unknown) => EmailBinding | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Reject a submission before anything is sent. Return a message to refuse.
|
|
21
|
+
* This is where a consumer puts required-field checks and spam rules.
|
|
22
|
+
*/
|
|
23
|
+
validate?: (body: Record<string, unknown>) => string | null;
|
|
24
|
+
/** Called with every channel outcome, delivered or not. Defaults to console. */
|
|
25
|
+
onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;
|
|
26
|
+
/** Injected in tests. */
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a POST handler for conversions.
|
|
31
|
+
*
|
|
32
|
+
* Returns 204 for a beacon and 200 with `{ success }` for a form, matching what
|
|
33
|
+
* `navigator.sendBeacon` and a fetch-submitting form each expect.
|
|
34
|
+
*
|
|
35
|
+
* **A failed notification does not fail the request.** The visitor has already
|
|
36
|
+
* submitted; refusing them because our mail did not go out turns a lost
|
|
37
|
+
* notification into a lost conversion. Failures are reported through `onResult`.
|
|
38
|
+
*/
|
|
39
|
+
declare function createConversionRoute(options: ConversionRouteOptions): ({
|
|
40
|
+
request,
|
|
41
|
+
locals
|
|
42
|
+
}: ConversionRequestContext) => Promise<Response>;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { ConversionRequestContext, ConversionRouteOptions, createConversionRoute };
|
|
45
|
+
//# sourceMappingURL=conversion.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;;;UAgCiB,wBAAA;EACf,OAAA,EAAS,OAAO;EAChB,MAAA;AAAA;AAAA,UAGe,sBAAA;EAe+B;EAb9C,OAAA,EAAS,aAAA;EAeT;;AAAG;AA0BL;;EAnCE,OAAA,IAAW,MAAA,cAAoB,YAAA;EAmCc;;;;EA9B7C,QAAA,IAAY,IAAA,EAAM,MAAA;EAiC4C;EA/B9D,QAAA,IAAY,MAAA,EAAQ,OAAA,CAAQ,UAAA,QAAkB,MAAA;EA+BuB;EA7BrE,GAAA;AAAA;;;;;;;;;AA6B8E;;iBAHhE,qBAAA,CAAsB,OAAA,EAAS,sBAAA;EAG/B,OAAA;EAAA;AAAA,GAAqB,wBAAA,KAA2B,OAAA,CAAQ,QAAA"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { notify } from "@getvitops/utils/notify";
|
|
2
|
+
import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
3
|
+
|
|
4
|
+
//#region src/routes/conversion.ts
|
|
5
|
+
/**
|
|
6
|
+
* The conversion endpoint, as a factory.
|
|
7
|
+
*
|
|
8
|
+
* A site's contact form and its `tel:` beacon both mean the same thing — someone
|
|
9
|
+
* got in touch — and both want the same treatment: read the attribution cookie
|
|
10
|
+
* off the request, build the event, notify whoever the config says. That is the
|
|
11
|
+
* same three lines in every project, so it ships here.
|
|
12
|
+
*
|
|
13
|
+
* What deliberately does **not** ship here is validation and business rules. A
|
|
14
|
+
* consumer's form has its own required fields, its own spam handling and its own
|
|
15
|
+
* response shape, and baking a guess at those in would make the factory something
|
|
16
|
+
* to fight rather than mount. The handler takes the parsed body and an optional
|
|
17
|
+
* `validate`; everything else is the caller's.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* // src/pages/api/track.ts
|
|
21
|
+
* export const prerender = false;
|
|
22
|
+
* import { createConversionRoute } from '@getvitops/astro/routes';
|
|
23
|
+
* import config from '../../site.config.json';
|
|
24
|
+
*
|
|
25
|
+
* export const POST = createConversionRoute({
|
|
26
|
+
* context: toNotifyContext(config),
|
|
27
|
+
* binding: (locals) => locals.runtime?.env?.EMAIL,
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
/** Fields that are never part of a submission's meaning. */
|
|
32
|
+
const CONTROL_FIELDS = new Set(["event", "phone"]);
|
|
33
|
+
function toFormData(body) {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, value] of Object.entries(body)) {
|
|
36
|
+
if (CONTROL_FIELDS.has(key)) continue;
|
|
37
|
+
if (value === void 0 || value === null) continue;
|
|
38
|
+
out[key] = String(value);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a POST handler for conversions.
|
|
44
|
+
*
|
|
45
|
+
* Returns 204 for a beacon and 200 with `{ success }` for a form, matching what
|
|
46
|
+
* `navigator.sendBeacon` and a fetch-submitting form each expect.
|
|
47
|
+
*
|
|
48
|
+
* **A failed notification does not fail the request.** The visitor has already
|
|
49
|
+
* submitted; refusing them because our mail did not go out turns a lost
|
|
50
|
+
* notification into a lost conversion. Failures are reported through `onResult`.
|
|
51
|
+
*/
|
|
52
|
+
function createConversionRoute(options) {
|
|
53
|
+
const { context, binding, validate, onResult, now = Date.now } = options;
|
|
54
|
+
return async ({ request, locals }) => {
|
|
55
|
+
let body;
|
|
56
|
+
try {
|
|
57
|
+
const parsed = await request.json();
|
|
58
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return Response.json({
|
|
59
|
+
success: false,
|
|
60
|
+
error: "Malformed request."
|
|
61
|
+
}, { status: 400 });
|
|
62
|
+
body = parsed;
|
|
63
|
+
} catch {
|
|
64
|
+
return Response.json({
|
|
65
|
+
success: false,
|
|
66
|
+
error: "Malformed request."
|
|
67
|
+
}, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
const isCall = body["event"] === "call";
|
|
70
|
+
if (isCall && typeof body["phone"] !== "string") return Response.json({
|
|
71
|
+
success: false,
|
|
72
|
+
error: "Missing phone."
|
|
73
|
+
}, { status: 400 });
|
|
74
|
+
const refusal = validate?.(body);
|
|
75
|
+
if (refusal) return Response.json({
|
|
76
|
+
success: false,
|
|
77
|
+
error: refusal
|
|
78
|
+
}, { status: 400 });
|
|
79
|
+
const result = await notify({
|
|
80
|
+
type: isCall ? "call" : "form",
|
|
81
|
+
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body) },
|
|
82
|
+
tracking: parseTrackingCookie(request.headers.get("cookie")),
|
|
83
|
+
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
84
|
+
ip: request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0,
|
|
85
|
+
at: now()
|
|
86
|
+
}, context, { email: binding?.(locals) });
|
|
87
|
+
if (onResult) onResult(result);
|
|
88
|
+
else for (const r of result.results) if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);
|
|
89
|
+
return isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
export { createConversionRoute };
|
|
95
|
+
//# sourceMappingURL=conversion.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:` beacon both mean the same thing — someone\n * got in touch — and both want the same treatment: read the attribution cookie\n * off the request, build the event, notify whoever the config says. That is the\n * same three lines in every project, so it ships here.\n *\n * What deliberately does **not** ship here is validation and business rules. A\n * consumer's form has its own required fields, its own spam handling and its own\n * response shape, and baking a guess at those in would make the factory something\n * to fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; everything else is the caller's.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { createConversionRoute } from '@getvitops/astro/routes';\n * import config from '../../site.config.json';\n *\n * export const POST = createConversionRoute({\n * context: toNotifyContext(config),\n * binding: (locals) => locals.runtime?.env?.EMAIL,\n * });\n * ```\n */\nimport { type EmailBinding, notify, type NotifyContext } from '@getvitops/utils/notify';\nimport { parseTrackingCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent } from '@getvitops/utils/tracking';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g.\n * `(locals) => locals.runtime.env.EMAIL`. Omit it in dev and the notification\n * is printed instead of sent.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks and spam rules.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not. Defaults to console. */\n onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;\n /** Injected in tests. */\n now?: () => number;\n}\n\n/** Fields that are never part of a submission's meaning. */\nconst CONTROL_FIELDS = new Set(['event', 'phone']);\n\nfunction toFormData(body: Record<string, unknown>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what\n * `navigator.sendBeacon` and a fetch-submitting form each expect.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const { context, binding, validate, onResult, now = Date.now } = options;\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n body = parsed as Record<string, unknown>;\n } catch {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n\n const isCall = body['event'] === 'call';\n if (isCall && typeof body['phone'] !== 'string') {\n return Response.json({ success: false, error: 'Missing phone.' }, { status: 400 });\n }\n\n const refusal = validate?.(body);\n if (refusal) return Response.json({ success: false, error: refusal }, { status: 400 });\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : { formData: toFormData(body) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n // Cloudflare's own header first; `x-forwarded-for` is the portable fallback\n // and may carry a proxy chain, whose first entry is the client.\n ip:\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined,\n at: now(),\n };\n\n const result = await notify(event, context, { email: binding?.(locals) });\n\n if (onResult) onResult(result);\n else\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n\n return isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0DA,MAAM,iBAAiB,IAAI,IAAI,CAAC,SAAS,OAAO,CAAC;AAEjD,SAAS,WAAW,MAAuD;CACzE,MAAM,MAA8B,CAAC;CACrC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,GAAG;EAC/C,IAAI,eAAe,IAAI,GAAG,GAAG;EAC7B,IAAI,UAAU,UAAa,UAAU,MAAM;EAC3C,IAAI,OAAO,OAAO,KAAK;CACzB;CACA,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,sBAAsB,SAAiC;CACrE,MAAM,EAAE,SAAS,SAAS,UAAU,UAAU,MAAM,KAAK,QAAQ;CAEjE,OAAO,OAAO,EAAE,SAAS,aAA0D;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,SAAkB,MAAM,QAAQ,KAAK;GAC3C,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;GAEvF,OAAO;EACT,QAAQ;GACN,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;EACvF;EAEA,MAAM,SAAS,KAAK,aAAa;EACjC,IAAI,UAAU,OAAO,KAAK,aAAa,UACrC,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;EAGnF,MAAM,UAAU,WAAW,IAAI;EAC/B,IAAI,SAAS,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;EAkBrF,MAAM,SAAS,MAAM,OAAO;GAf1B,MAAM,SAAS,SAAS;GACxB,GAAI,SACA,EAAE,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,QAAQ,SAAS,EAAE,EAAE,IACpD,EAAE,UAAU,WAAW,IAAI,EAAE;GACjC,UAAU,oBAAoB,QAAQ,QAAQ,IAAI,QAAQ,CAAC;GAC3D,WAAW,QAAQ,QAAQ,IAAI,YAAY,KAAK;GAGhD,IACE,QAAQ,QAAQ,IAAI,kBAAkB,KACtC,QAAQ,QAAQ,IAAI,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,KAC5D;GACF,IAAI,IAAI;EAGsB,GAAG,SAAS,EAAE,OAAO,UAAU,MAAM,EAAE,CAAC;EAExE,IAAI,UAAU,SAAS,MAAM;OAE3B,KAAK,MAAM,KAAK,OAAO,SACrB,IAAI,CAAC,EAAE,MAAM,QAAQ,KAAK,mCAAmC,EAAE,QAAQ,KAAK,EAAE,QAAQ;EAG1F,OAAO,SAAS,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC,IAAI,SAAS,KAAK,EAAE,SAAS,KAAK,CAAC;CACvF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/astro",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -19,9 +19,17 @@
|
|
|
19
19
|
"src/components/Popover.astro",
|
|
20
20
|
"src/components/Details.astro",
|
|
21
21
|
"src/components/Drawer.astro",
|
|
22
|
+
"src/components/NavShell.astro",
|
|
23
|
+
"src/components/NavShellToggle.astro",
|
|
22
24
|
"src/schemas",
|
|
23
25
|
"virtual.d.ts",
|
|
24
|
-
"CHANGELOG.md"
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"src/components/Tracking.astro",
|
|
28
|
+
"src/scripts/tracking.ts",
|
|
29
|
+
"src/tracking.ts",
|
|
30
|
+
"src/components/Tree.astro",
|
|
31
|
+
"src/components/TreeNode.astro",
|
|
32
|
+
"src/components/tree.ts"
|
|
25
33
|
],
|
|
26
34
|
"type": "module",
|
|
27
35
|
"types": "./dist/index.d.mts",
|
|
@@ -30,10 +38,15 @@
|
|
|
30
38
|
"types": "./dist/index.d.mts",
|
|
31
39
|
"import": "./dist/index.mjs"
|
|
32
40
|
},
|
|
41
|
+
"./routes": {
|
|
42
|
+
"types": "./dist/routes/conversion.d.mts",
|
|
43
|
+
"import": "./dist/routes/conversion.mjs"
|
|
44
|
+
},
|
|
33
45
|
"./Head.astro": "./src/components/Head.astro",
|
|
34
46
|
"./Seo.astro": "./src/components/Seo.astro",
|
|
35
47
|
"./Analytics.astro": "./src/components/Analytics.astro",
|
|
36
48
|
"./CookieConsent.astro": "./src/components/CookieConsent.astro",
|
|
49
|
+
"./Tracking.astro": "./src/components/Tracking.astro",
|
|
37
50
|
"./components/Subgrid.astro": "./src/components/Subgrid.astro",
|
|
38
51
|
"./components/Cards.astro": "./src/components/Cards.astro",
|
|
39
52
|
"./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
|
|
@@ -42,6 +55,10 @@
|
|
|
42
55
|
"./components/Popover.astro": "./src/components/Popover.astro",
|
|
43
56
|
"./components/Details.astro": "./src/components/Details.astro",
|
|
44
57
|
"./components/Drawer.astro": "./src/components/Drawer.astro",
|
|
58
|
+
"./components/NavShell.astro": "./src/components/NavShell.astro",
|
|
59
|
+
"./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
|
|
60
|
+
"./components/Tree.astro": "./src/components/Tree.astro",
|
|
61
|
+
"./components/tree.ts": "./src/components/tree.ts",
|
|
45
62
|
"./schemas/*": "./src/schemas/*",
|
|
46
63
|
"./package.json": "./package.json"
|
|
47
64
|
},
|
|
@@ -49,10 +66,10 @@
|
|
|
49
66
|
"access": "public"
|
|
50
67
|
},
|
|
51
68
|
"dependencies": {
|
|
52
|
-
"@getvitops/
|
|
53
|
-
"@getvitops/
|
|
54
|
-
"@getvitops/
|
|
55
|
-
"@getvitops/
|
|
69
|
+
"@getvitops/generator": "4.0.0",
|
|
70
|
+
"@getvitops/vite": "4.0.0",
|
|
71
|
+
"@getvitops/core": "4.0.0",
|
|
72
|
+
"@getvitops/utils": "4.0.0"
|
|
56
73
|
},
|
|
57
74
|
"devDependencies": {
|
|
58
75
|
"@astrojs/sitemap": "^3.7.3",
|
package/src/analytics.ts
CHANGED
|
@@ -136,7 +136,7 @@ export interface ResolvedAnalytics {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
export interface ResolveAnalyticsContext {
|
|
139
|
-
/** Is the consent gate enabled on this site (`
|
|
139
|
+
/** Is the consent gate enabled on this site (`vitops({ consent })`)? */
|
|
140
140
|
consent?: boolean;
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* <Analytics /> — the site's analytics tags, off the critical path and behind the
|
|
4
|
-
* consent gate. Place in your layout's <head>; requires the
|
|
4
|
+
* consent gate. Place in your layout's <head>; requires the vitops()
|
|
5
5
|
* integration, which supplies `virtual:getvitops/head`.
|
|
6
6
|
*
|
|
7
7
|
* A dumb renderer over `resolveAnalytics()` (src/analytics.ts) — every decision
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* the critical-path cost the `idle` strategy exists to avoid.
|
|
20
20
|
*/
|
|
21
21
|
import { type AnalyticsStrategy, resolveAnalytics } from '../analytics.ts';
|
|
22
|
-
// @ts-ignore — virtual module provided by the
|
|
22
|
+
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
23
23
|
import head from 'virtual:getvitops/head';
|
|
24
24
|
|
|
25
25
|
interface Props {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* <CookieConsent /> — the consent banner. Place near the end of <body>; requires
|
|
4
|
-
* the
|
|
4
|
+
* the vitops() integration with `consent` enabled, which is what loads
|
|
5
5
|
* `@getvitops/core/consent` (the gate + `<wc-consent>`).
|
|
6
6
|
*
|
|
7
7
|
* This renders the *fallback*: a real, semantic form with real buttons. The
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
* writes one — otherwise the thing asking permission would be the thing needing
|
|
15
15
|
* it.
|
|
16
16
|
*
|
|
17
|
+
* **`categories` is what the banner *can* ask, not what it will.** A row here is
|
|
18
|
+
* markup, and a hidden row costs nothing, so the default is generous — every
|
|
19
|
+
* category the site could plausibly gate. `<wc-consent>` reveals only the rows
|
|
20
|
+
* something has actually demanded and the visitor hasn't answered, so being
|
|
21
|
+
* generous here buys coverage without buying interruptions. Narrow it only to
|
|
22
|
+
* refuse a category outright.
|
|
23
|
+
*
|
|
17
24
|
* **Consent is not an analytics feature.** The same gate covers A/B assignment,
|
|
18
25
|
* personalisation and embedded third-party content: mark any of them
|
|
19
26
|
* `data-consent="<category>"` and they wait on the same choice. Which categories
|
|
20
27
|
* this banner offers therefore comes from the integration, not from
|
|
21
28
|
* `<Analytics />`.
|
|
22
29
|
*/
|
|
23
|
-
// @ts-ignore — virtual module provided by the
|
|
30
|
+
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
24
31
|
import head from 'virtual:getvitops/head';
|
|
25
32
|
import type { OptionalConsentCategory } from '../analytics.ts';
|
|
26
33
|
|
|
@@ -50,7 +57,7 @@ const DEFAULT_LABELS: Record<OptionalConsentCategory, string> = {
|
|
|
50
57
|
};
|
|
51
58
|
|
|
52
59
|
const {
|
|
53
|
-
categories = head.consentCategories ?? ['analytics'],
|
|
60
|
+
categories = head.consentCategories ?? ['analytics', 'preferences'],
|
|
54
61
|
policyUrl = head.consentPolicyUrl ?? undefined,
|
|
55
62
|
policyLabel = 'Cookie notice',
|
|
56
63
|
heading = 'Cookies on this site',
|
|
@@ -62,9 +69,6 @@ const {
|
|
|
62
69
|
} = Astro.props;
|
|
63
70
|
|
|
64
71
|
const wording = { ...DEFAULT_LABELS, ...labels };
|
|
65
|
-
// One category is a yes/no question; checkboxes for it would be a form standing in
|
|
66
|
-
// for two buttons that already say the same thing.
|
|
67
|
-
const choose = categories.length > 1;
|
|
68
72
|
---
|
|
69
73
|
|
|
70
74
|
<wc-consent hidden {...noReloadOnRevoke ? { 'reload-on-revoke': 'false' } : {}}>
|
|
@@ -86,20 +90,24 @@ const choose = categories.length > 1;
|
|
|
86
90
|
{policyUrl && <a class="link" href={policyUrl}>{policyLabel}</a>}
|
|
87
91
|
</div>
|
|
88
92
|
|
|
89
|
-
{
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
{/*
|
|
94
|
+
A row per offered category, always rendered. Which of them the visitor
|
|
95
|
+
actually sees is a runtime decision — `data-consent-row` is how
|
|
96
|
+
`<wc-consent>` addresses one to reveal or hide it, and it hides the whole
|
|
97
|
+
group when a showing is about a single category.
|
|
98
|
+
*/}
|
|
99
|
+
<div class="consent__options">
|
|
100
|
+
{categories.map((category) => (
|
|
101
|
+
<label data-consent-row={category}>
|
|
102
|
+
<input type="checkbox" name={category} />
|
|
103
|
+
{wording[category]}
|
|
104
|
+
</label>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
99
107
|
|
|
100
108
|
<div class="consent__actions">
|
|
101
109
|
<button type="button" class="btn" data-consent-reject>{rejectLabel}</button>
|
|
102
|
-
|
|
110
|
+
<button type="button" class="btn" data-consent-save>{saveLabel}</button>
|
|
103
111
|
<button type="button" class="cta" data-consent-accept>{acceptLabel}</button>
|
|
104
112
|
</div>
|
|
105
113
|
</form>
|
|
@@ -15,7 +15,7 @@ interface Props {
|
|
|
15
15
|
/**
|
|
16
16
|
* Map a semantic name onto a fully-qualified one before rendering.
|
|
17
17
|
*
|
|
18
|
-
* @deprecated The `icons` option on `
|
|
18
|
+
* @deprecated The `icons` option on `vitops()` now does this centrally, so
|
|
19
19
|
* the site's set is configured once instead of threaded through every call
|
|
20
20
|
* site. Left in place because removing it would break consumers passing one;
|
|
21
21
|
* when omitted, `<Icon />` resolves through the site config.
|
|
@@ -19,7 +19,7 @@ interface Props {
|
|
|
19
19
|
/**
|
|
20
20
|
* Map a semantic name onto a fully-qualified one before rendering.
|
|
21
21
|
*
|
|
22
|
-
* @deprecated The `icons` option on `
|
|
22
|
+
* @deprecated The `icons` option on `vitops()` now does this centrally, so
|
|
23
23
|
* the site's set is configured once instead of threaded through every call
|
|
24
24
|
* site. Left in place because removing it would break consumers passing one;
|
|
25
25
|
* when omitted, `<Icon />` resolves through the site config.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* <Head /> — the Vitops design system's <head> contributions. Place inside your
|
|
4
|
-
* layout's <head>. Requires the
|
|
4
|
+
* layout's <head>. Requires the vitops() integration (it provides the
|
|
5
5
|
* `virtual:getvitops/head` config + copies the assets into public/).
|
|
6
6
|
*
|
|
7
7
|
* Renders: favicon + PWA manifest tags, the webfont <Font /> tags for families
|
|
8
|
-
* declared via
|
|
8
|
+
* declared via vitops({ fonts }), and the web-component runtime scripts in
|
|
9
9
|
* canonical order/priority. It does NOT emit a stylesheet <link> — the CSS is
|
|
10
10
|
* imported into Astro's graph by the integration and Astro emits that link.
|
|
11
11
|
*/
|
|
12
|
-
// @ts-ignore — virtual module provided by the
|
|
12
|
+
// @ts-ignore — virtual module provided by the vitops() integration at build time
|
|
13
13
|
import head from 'virtual:getvitops/head';
|
|
14
14
|
// @ts-ignore — `Font` is provided by Astro's Fonts API (astro >= 7)
|
|
15
15
|
import { Font } from 'astro:assets';
|
|
@@ -41,6 +41,33 @@ const THEME_SCRIPT =
|
|
|
41
41
|
`if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;` +
|
|
42
42
|
`document.documentElement.style.colorScheme=s+' only'}}catch(e){}`;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The consent gate's build-time signal: "something on this page may need
|
|
46
|
+
* permission, and the real gate hasn't loaded yet."
|
|
47
|
+
*
|
|
48
|
+
* `consent.js` and `elements.js` are both deferred modules with no ordering
|
|
49
|
+
* guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
|
|
50
|
+
* before the gate exists. Without this, a caller probing for
|
|
51
|
+
* `window.vitopsConsent` in that window would see nothing, correctly conclude
|
|
52
|
+
* that sites without the gate should just store — and store. The stub closes the
|
|
53
|
+
* window by making the gate's *existence* known synchronously in `<head>`, which
|
|
54
|
+
* is the only place that beats every deferred bundle.
|
|
55
|
+
*
|
|
56
|
+
* It answers `false` to everything, because nothing is granted until the store
|
|
57
|
+
* has been read, and queues what it was asked so `consent/runtime.ts` can replay
|
|
58
|
+
* it on evaluation. `q` entries are `[category]` for `require()` and
|
|
59
|
+
* `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
|
|
60
|
+
* stub in `analytics.ts` and for the same reason.
|
|
61
|
+
*
|
|
62
|
+
* Emitted only when the gate is on: its absence is what tells a caller the site
|
|
63
|
+
* has no gate at all.
|
|
64
|
+
*/
|
|
65
|
+
const CONSENT_STUB =
|
|
66
|
+
`window.vitopsConsent=window.vitopsConsent||{q:[],` +
|
|
67
|
+
`require:function(c){this.q.push([c]);return false},` +
|
|
68
|
+
`request:function(c){var s=this;return new Promise(function(r){s.q.push([c,r])})},` +
|
|
69
|
+
`granted:function(){return false},needed:function(){return false}};`;
|
|
70
|
+
|
|
44
71
|
/*
|
|
45
72
|
* No `<html>` class flips here on purpose. `animation.css` used to need `no-js`
|
|
46
73
|
* and `.no-scroll-timeline` set before first paint; it now asks the platform
|
|
@@ -61,7 +88,7 @@ const THEME_SCRIPT =
|
|
|
61
88
|
{favicons && head.themeColor && <meta name="theme-color" content={head.themeColor} />}
|
|
62
89
|
|
|
63
90
|
{/*
|
|
64
|
-
Webfonts declared via
|
|
91
|
+
Webfonts declared via vitops({ fonts }). Astro's `fonts:` config resolves and
|
|
65
92
|
optimises the files; this component is what actually puts the @font-face on the page,
|
|
66
93
|
so without it the config is inert and every --font-* token falls through to its
|
|
67
94
|
fallback stack.
|
|
@@ -77,15 +104,26 @@ const THEME_SCRIPT =
|
|
|
77
104
|
<Font cssVariable={f.cssVariable} preload={f.preload} />
|
|
78
105
|
))}
|
|
79
106
|
|
|
80
|
-
{/* Set by
|
|
107
|
+
{/* Set by vitops({ sitemap }); null when no sitemap was registered. */}
|
|
81
108
|
{head.sitemap && <link rel="sitemap" type="application/xml" href={head.sitemap} />}
|
|
82
109
|
|
|
110
|
+
{/*
|
|
111
|
+
The consent stub — synchronous, and ahead of every module script on purpose.
|
|
112
|
+
See CONSENT_STUB above: it exists so that code which loads before the gate can
|
|
113
|
+
tell "the gate isn't here yet" from "this site has no gate", and queue rather
|
|
114
|
+
than assume it may store. Keyed on `head.consent` (is the gate on?) rather than
|
|
115
|
+
`head.consentRuntime` (does anything need scheduling?) — a site whose only tag
|
|
116
|
+
is cookieless loads the runtime to schedule it but gates nothing, so there is
|
|
117
|
+
nothing for a caller to queue behind.
|
|
118
|
+
*/}
|
|
119
|
+
{head.consent && <script is:inline set:html={CONSENT_STUB} />}
|
|
120
|
+
|
|
83
121
|
{webComponents && (
|
|
84
122
|
<>
|
|
85
123
|
{/*
|
|
86
124
|
Apply the remembered colour scheme before first paint.
|
|
87
125
|
|
|
88
|
-
`<color-scheme-toggle>` persists the choice to localStorage, but it loads
|
|
126
|
+
`<wc-color-scheme-toggle>` persists the choice to localStorage, but it loads
|
|
89
127
|
as a deferred module — so without this every navigation would paint the
|
|
90
128
|
light token set and flip to dark once the element upgraded. Synchronous and
|
|
91
129
|
inline on purpose: it has to run before the body renders.
|
|
@@ -112,7 +150,7 @@ const THEME_SCRIPT =
|
|
|
112
150
|
{head.consentRuntime && <script is:inline type="module" src={`${wc}/consent.js`}></script>}
|
|
113
151
|
|
|
114
152
|
{/*
|
|
115
|
-
The live theme editor — a separate, opt-in bundle (
|
|
153
|
+
The live theme editor — a separate, opt-in bundle (vitops({ editor: true })).
|
|
116
154
|
It is tooling, not part of the runtime, so it stays out of elements.js and off
|
|
117
155
|
every page that doesn't ask for it. Loads independently of `webComponents`:
|
|
118
156
|
<wc-theme-editor> registers itself and pulls in no framework element.
|
|
@@ -67,13 +67,13 @@ if (icons.engine === 'inline' && qualified) {
|
|
|
67
67
|
svg = await loadIconSvg(qualified, { resolveFrom: icons.root });
|
|
68
68
|
if (!svg)
|
|
69
69
|
console.warn(
|
|
70
|
-
`[
|
|
70
|
+
`[vitops] <Icon name="${name}"> — "${qualified}" not found. Is ` +
|
|
71
71
|
`@iconify-json/${qualified.slice(0, qualified.indexOf(':'))} installed?`,
|
|
72
72
|
);
|
|
73
73
|
} else if (icons.engine === 'none') {
|
|
74
74
|
console.warn(
|
|
75
|
-
`[
|
|
76
|
-
'Add `icons: true` to
|
|
75
|
+
`[vitops] <Icon name="${name}"> rendered but icons are not configured. ` +
|
|
76
|
+
'Add `icons: true` to vitops() in astro.config.',
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* NavShell — a navigation aside beside main content at width, collapsing to a
|
|
4
|
+
* toggle + drawer below it. Markup only: every declaration lives in the
|
|
5
|
+
* framework's `navshell` pattern (patterns/navshell.css), which is emitted in
|
|
6
|
+
* all three output formats.
|
|
7
|
+
*
|
|
8
|
+
* It nests. A docs page is typically two of these — a site nav wrapping an
|
|
9
|
+
* on-this-page nav — promoting at different breakpoints:
|
|
10
|
+
*
|
|
11
|
+
* <NavShell id="site-nav" label="Documentation" breakpoint="md">
|
|
12
|
+
* <Fragment slot="nav">…</Fragment>
|
|
13
|
+
* <main>
|
|
14
|
+
* <NavShell id="page-toc" label="On this page" breakpoint="lg" side="end">
|
|
15
|
+
* <Fragment slot="nav">…</Fragment>
|
|
16
|
+
* …prose…
|
|
17
|
+
* </NavShell>
|
|
18
|
+
* </main>
|
|
19
|
+
* </NavShell>
|
|
20
|
+
*
|
|
21
|
+
* Nothing here may reach for a Tailwind utility: Tailwind doesn't scan
|
|
22
|
+
* node_modules, so a class only it can emit is silently never generated — and
|
|
23
|
+
* classes in the generator's TW_CLASH list are stripped from the framework's own
|
|
24
|
+
* tailwind bundle by design. `navshell*` and `drawer*` are in neither list.
|
|
25
|
+
*
|
|
26
|
+
* No runtime JS: the toggle is `<button popovertarget>` against a `[popover]`
|
|
27
|
+
* panel, so it works from the first paint and degrades to a visible panel if the
|
|
28
|
+
* Popover API is missing.
|
|
29
|
+
*/
|
|
30
|
+
interface Props {
|
|
31
|
+
/**
|
|
32
|
+
* The panel's id, and the `popovertarget` the toggle points at.
|
|
33
|
+
*
|
|
34
|
+
* Required, never generated. It IS the toggle↔panel contract, and a generated
|
|
35
|
+
* id would make the one real hazard of nesting — two instances colliding on
|
|
36
|
+
* the same id, so the outer toggle opens the inner panel — invisible rather
|
|
37
|
+
* than impossible. Making the author name each instance makes a collision a
|
|
38
|
+
* thing you can see in the markup.
|
|
39
|
+
*/
|
|
40
|
+
id: string;
|
|
41
|
+
/** Accessible name for the `<nav>`, e.g. "Documentation" / "On this page". */
|
|
42
|
+
label: string;
|
|
43
|
+
/** Container width at which the aside appears. Default `md` (48rem). */
|
|
44
|
+
breakpoint?: 'sm' | 'md' | 'lg' | 'xl';
|
|
45
|
+
/** Which side the aside sits on at width. Default `start`. */
|
|
46
|
+
side?: 'start' | 'end';
|
|
47
|
+
/**
|
|
48
|
+
* Aside column width once promoted, e.g. `'14rem'`. Defaults to the design
|
|
49
|
+
* system's `--width-nav` token rather than a literal, so a site retunes every
|
|
50
|
+
* nav column in one place.
|
|
51
|
+
*/
|
|
52
|
+
size?: string;
|
|
53
|
+
/** Accessible name for the toggle. Defaults to `Open ${label}`. */
|
|
54
|
+
toggleLabel?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Where the toggle lives. `'inline'` renders a `.navshell__bar` row above the
|
|
57
|
+
* content; `'external'` renders none, for when a `<NavShellToggle>` elsewhere
|
|
58
|
+
* (typically the site header) opens this shell instead.
|
|
59
|
+
*
|
|
60
|
+
* Two NESTED shells should not both be `'inline'`: their bars are sticky at
|
|
61
|
+
* the same offset and land on top of each other. Hoist the outer one.
|
|
62
|
+
*/
|
|
63
|
+
toggle?: 'inline' | 'external';
|
|
64
|
+
class?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
id,
|
|
69
|
+
label,
|
|
70
|
+
breakpoint = 'md',
|
|
71
|
+
side = 'start',
|
|
72
|
+
// Not a literal: the default REFERENCES the design system's token, so an
|
|
73
|
+
// unsized instance still tracks `--width-nav`. A literal here would shadow it
|
|
74
|
+
// on every instance and quietly make the token unreachable through this
|
|
75
|
+
// component, which is the only way most consumers will use the pattern.
|
|
76
|
+
size = 'var(--width-nav, 16rem)',
|
|
77
|
+
toggleLabel,
|
|
78
|
+
toggle = 'inline',
|
|
79
|
+
class: className,
|
|
80
|
+
} = Astro.props;
|
|
81
|
+
|
|
82
|
+
// Always emitted, even when the author passes no `size`. `--navshell-size`
|
|
83
|
+
// inherits (it can't be registered as non-inheriting — a registered property's
|
|
84
|
+
// initial-value must be computationally independent, and this one is in rem), so
|
|
85
|
+
// without this an inner instance would silently adopt an outer instance's column
|
|
86
|
+
// width. Writing it on every root severs the inheritance at each boundary.
|
|
87
|
+
const style = `--navshell-size: ${size}`;
|
|
88
|
+
|
|
89
|
+
// The drawer slides in from the side its aside will occupy, so the motion points
|
|
90
|
+
// at where the panel is going to live.
|
|
91
|
+
const edge = side === 'end' ? 'drawer--right' : 'drawer--left';
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
<div
|
|
95
|
+
class:list={['navshell', `navshell--bp-${breakpoint}`, side === 'end' && 'navshell--end', className]}
|
|
96
|
+
style={style}
|
|
97
|
+
>
|
|
98
|
+
{toggle === 'inline' && (
|
|
99
|
+
<div class="navshell__bar">
|
|
100
|
+
<slot name="toggle">
|
|
101
|
+
<button class="navshell__toggle" popovertarget={id} aria-label={toggleLabel ?? `Open ${label}`}>
|
|
102
|
+
<slot name="toggle-label">{label}</slot>
|
|
103
|
+
</button>
|
|
104
|
+
</slot>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div id={id} popover class:list={['navshell__panel', 'drawer', edge]}>
|
|
109
|
+
<button
|
|
110
|
+
class="navshell__close"
|
|
111
|
+
popovertarget={id}
|
|
112
|
+
popovertargetaction="hide"
|
|
113
|
+
aria-label={`Close ${label}`}>×</button
|
|
114
|
+
>
|
|
115
|
+
<nav class="navshell__nav" aria-label={label}>
|
|
116
|
+
<slot name="nav" />
|
|
117
|
+
</nav>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div class="navshell__main">
|
|
121
|
+
<slot />
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* NavShellToggle — a `<NavShell>` toggle rendered outside its shell, so it can
|
|
4
|
+
* sit in a site header or navbar rather than in a row of its own.
|
|
5
|
+
*
|
|
6
|
+
* Two nested shells both rendering their own sticky toggle row put those rows at
|
|
7
|
+
* the same offset, on top of each other. Hoisting the outer one into the header
|
|
8
|
+
* — which is already sticky — leaves a single in-flow bar and no conflict. It is
|
|
9
|
+
* also simply where a site menu button belongs.
|
|
10
|
+
*
|
|
11
|
+
* <header class="navbar">
|
|
12
|
+
* <a href="/">Brand</a>
|
|
13
|
+
* <NavShellToggle for="site-nav" label="Menu" breakpoint="md" />
|
|
14
|
+
* </header>
|
|
15
|
+
* …
|
|
16
|
+
* <NavShell id="site-nav" label="Documentation" breakpoint="md" toggle="external">
|
|
17
|
+
*
|
|
18
|
+
* `breakpoint` MUST match the shell's: outside `.navshell` the toggle cannot read
|
|
19
|
+
* the shell's internal "am I wide?" flag (a style query resolves against an
|
|
20
|
+
* ancestor container, and the header is not one), so it carries its own copy.
|
|
21
|
+
* Mismatch them and you get a toggle visible next to an expanded sidebar, or no
|
|
22
|
+
* way to open the nav at all.
|
|
23
|
+
*
|
|
24
|
+
* No runtime JS: `popovertarget` is an id reference, which works from anywhere in
|
|
25
|
+
* the document.
|
|
26
|
+
*/
|
|
27
|
+
interface Props {
|
|
28
|
+
/** The `id` of the `<NavShell>` this opens. */
|
|
29
|
+
for: string;
|
|
30
|
+
/** Visible text (and accessible name, unless `aria-label` is given). */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Must match the shell's `breakpoint`. Default `md`. */
|
|
33
|
+
breakpoint?: 'sm' | 'md' | 'lg' | 'xl';
|
|
34
|
+
class?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { for: target, label, breakpoint = 'md', class: className } = Astro.props;
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<button
|
|
41
|
+
class:list={['navshell-toggle', `navshell-toggle--bp-${breakpoint}`, className]}
|
|
42
|
+
popovertarget={target}
|
|
43
|
+
>
|
|
44
|
+
<slot>{label}</slot>
|
|
45
|
+
</button>
|