@cobdfamily/clf-core 7.0.1
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 +250 -0
- package/dist/_variables.scss +973 -0
- package/dist/components/cobd-embed.d.ts +4 -0
- package/dist/components/cobd-embed.js +163 -0
- package/dist/components/cobd-nav.d.ts +1 -0
- package/dist/components/cobd-nav.js +383 -0
- package/dist/components/cobd-support.d.ts +26 -0
- package/dist/components/cobd-support.js +296 -0
- package/dist/components/font-scale-toggle.d.ts +9 -0
- package/dist/components/font-scale-toggle.js +159 -0
- package/dist/components/forms/checkbox.d.ts +1 -0
- package/dist/components/forms/checkbox.js +118 -0
- package/dist/components/forms/common.d.ts +17 -0
- package/dist/components/forms/common.js +137 -0
- package/dist/components/forms/index.d.ts +5 -0
- package/dist/components/forms/index.js +13 -0
- package/dist/components/forms/select.d.ts +1 -0
- package/dist/components/forms/select.js +132 -0
- package/dist/components/forms/submit.d.ts +1 -0
- package/dist/components/forms/submit.js +78 -0
- package/dist/components/forms/textarea.d.ts +1 -0
- package/dist/components/forms/textarea.js +95 -0
- package/dist/components/forms/textfield.d.ts +1 -0
- package/dist/components/forms/textfield.js +125 -0
- package/dist/components/index.d.ts +7 -0
- package/dist/components/index.js +33 -0
- package/dist/components/theme-toggle.d.ts +10 -0
- package/dist/components/theme-toggle.js +130 -0
- package/dist/i18n/chrome.json +94 -0
- package/dist/navs/cobd.ca.json +83 -0
- package/dist/navs/more-cobd.json +60 -0
- package/dist/navs.d.ts +27 -0
- package/dist/navs.js +193 -0
- package/dist/theming/font-scale-paint.d.ts +0 -0
- package/dist/theming/font-scale-paint.js +32 -0
- package/dist/theming/init.d.ts +1 -0
- package/dist/theming/init.js +19 -0
- package/dist/theming/runtime.d.ts +22 -0
- package/dist/theming/runtime.js +333 -0
- package/dist/tokens.css +972 -0
- package/dist/tokens.json +97 -0
- package/dist/tokens.scss +95 -0
- package/package.json +123 -0
- package/src/navs/schema.json +64 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// <cobd-embed [service="https://..."]>
|
|
2
|
+
// <a href="https://target.example/...">
|
|
3
|
+
// Optional link text -- used as the iframe title
|
|
4
|
+
// and as the screen-reader fallback when JS is
|
|
5
|
+
// off.
|
|
6
|
+
// </a>
|
|
7
|
+
// </cobd-embed>
|
|
8
|
+
//
|
|
9
|
+
// Progressive-enhancement embed. Authoring shape
|
|
10
|
+
// is just an anchor; on connect, the element
|
|
11
|
+
// upgrades to a sandboxed iframe pointing at the
|
|
12
|
+
// embed microservice at embed.openapis.ca/v/embed.
|
|
13
|
+
//
|
|
14
|
+
// Why the proxy: provider embeds (YouTube IFrame
|
|
15
|
+
// API, Twitter widgets.js, Bluesky, etc.) ship
|
|
16
|
+
// inline scripts and load third-party resources.
|
|
17
|
+
// Inlining them on the consumer page would force
|
|
18
|
+
// every consumer CSP to whitelist every provider
|
|
19
|
+
// origin. Instead, the proxy returns a self-
|
|
20
|
+
// contained HTML doc; we iframe that doc; the
|
|
21
|
+
// provider scripts run inside embed.openapis.ca's
|
|
22
|
+
// origin and never touch the consumer's CSP.
|
|
23
|
+
//
|
|
24
|
+
// Light-DOM only. No shadow root, no slot routing.
|
|
25
|
+
// The anchor stays in the DOM (visible below the
|
|
26
|
+
// iframe by default) so screen-reader users can
|
|
27
|
+
// always escape to the real URL -- some embeds
|
|
28
|
+
// trap focus inside provider iframes badly, and
|
|
29
|
+
// the explicit click-through is the seatbelt.
|
|
30
|
+
//
|
|
31
|
+
// Set the `service=` attribute to override the
|
|
32
|
+
// default proxy URL (e.g. for COBD-internal staging
|
|
33
|
+
// against a different embed deployment). Defaults
|
|
34
|
+
// to https://embed.openapis.ca/v/embed.
|
|
35
|
+
const DEFAULT_SERVICE = "https://embed.openapis.ca/v1/embed";
|
|
36
|
+
const STYLE_ID = "cobd-embed-base-style";
|
|
37
|
+
const BASE_STYLE = `
|
|
38
|
+
cobd-embed {
|
|
39
|
+
display: block;
|
|
40
|
+
position: relative;
|
|
41
|
+
width: 100%;
|
|
42
|
+
aspect-ratio: 16 / 9;
|
|
43
|
+
}
|
|
44
|
+
cobd-embed iframe {
|
|
45
|
+
width: 100%;
|
|
46
|
+
height: 100%;
|
|
47
|
+
border: 0;
|
|
48
|
+
display: block;
|
|
49
|
+
}
|
|
50
|
+
/* Hide the original anchor visually once the
|
|
51
|
+
iframe is in place, but keep it reachable in
|
|
52
|
+
the DOM (and to assistive tech via the
|
|
53
|
+
.cobd-embed-fallback link rendered below the
|
|
54
|
+
iframe). The original anchor is moved out of
|
|
55
|
+
tab order via tabindex=-1 in TS; this CSS
|
|
56
|
+
just hides it visually. */
|
|
57
|
+
cobd-embed > a[hidden] {
|
|
58
|
+
display: none !important;
|
|
59
|
+
}
|
|
60
|
+
.cobd-embed-fallback {
|
|
61
|
+
display: block;
|
|
62
|
+
margin-top: var(--cobd-spacing-xs, 4px);
|
|
63
|
+
font-size: 0.875rem;
|
|
64
|
+
color: var(--cobd-color-link, #3a6fd1);
|
|
65
|
+
}
|
|
66
|
+
.cobd-embed-fallback:focus-visible {
|
|
67
|
+
outline: 2px solid currentColor;
|
|
68
|
+
outline-offset: 2px;
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
function ensureBaseStyle() {
|
|
72
|
+
if (typeof document === "undefined")
|
|
73
|
+
return;
|
|
74
|
+
if (document.getElementById(STYLE_ID))
|
|
75
|
+
return;
|
|
76
|
+
const style = document.createElement("style");
|
|
77
|
+
style.id = STYLE_ID;
|
|
78
|
+
style.textContent = BASE_STYLE;
|
|
79
|
+
document.head.appendChild(style);
|
|
80
|
+
}
|
|
81
|
+
// Hostname-only display string for the fallback
|
|
82
|
+
// link's "Open at <host>". Using URL() means we
|
|
83
|
+
// don't need a regex and get the parser's
|
|
84
|
+
// normalisation for free (lowercased host, IDN
|
|
85
|
+
// handling). Falls back to the raw URL string if
|
|
86
|
+
// parsing throws -- defensive against an authoring
|
|
87
|
+
// typo. We never throw out of connectedCallback.
|
|
88
|
+
function hostOf(url) {
|
|
89
|
+
try {
|
|
90
|
+
return new URL(url).hostname;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return url;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
class CobdEmbedElement extends HTMLElement {
|
|
97
|
+
connectedCallback() {
|
|
98
|
+
ensureBaseStyle();
|
|
99
|
+
// Bail out idempotently if we've already
|
|
100
|
+
// upgraded -- connectedCallback can fire
|
|
101
|
+
// again when the element moves in the DOM.
|
|
102
|
+
if (this.querySelector("iframe"))
|
|
103
|
+
return;
|
|
104
|
+
const anchor = this.querySelector("a[href]");
|
|
105
|
+
if (!anchor) {
|
|
106
|
+
// No authoring content to upgrade. Leave
|
|
107
|
+
// the element's contents alone so the
|
|
108
|
+
// author can iterate without a flash.
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const href = anchor.href;
|
|
112
|
+
if (!href)
|
|
113
|
+
return;
|
|
114
|
+
const title = (anchor.textContent || "").trim()
|
|
115
|
+
|| hostOf(href);
|
|
116
|
+
const service = this.getAttribute("service")
|
|
117
|
+
|| DEFAULT_SERVICE;
|
|
118
|
+
const iframe = document.createElement("iframe");
|
|
119
|
+
iframe.src = service + "?url="
|
|
120
|
+
+ encodeURIComponent(href);
|
|
121
|
+
iframe.title = title;
|
|
122
|
+
iframe.loading = "lazy";
|
|
123
|
+
iframe.referrerPolicy = "no-referrer";
|
|
124
|
+
// No allow-same-origin: the proxy origin gets
|
|
125
|
+
// a unique opaque origin, so its scripts can
|
|
126
|
+
// still run (allow-scripts) and load child
|
|
127
|
+
// iframes for the provider, but can't act on
|
|
128
|
+
// cookies for embed.openapis.ca. Inner
|
|
129
|
+
// provider iframes have their own origin
|
|
130
|
+
// regardless.
|
|
131
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-popups "
|
|
132
|
+
+ "allow-popups-to-escape-sandbox");
|
|
133
|
+
// `allow` is the modern feature-policy
|
|
134
|
+
// surface; opt fullscreen in so video
|
|
135
|
+
// providers can present their player
|
|
136
|
+
// controls. Audio/encrypted-media are
|
|
137
|
+
// commonly needed for SoundCloud / Spotify.
|
|
138
|
+
iframe.setAttribute("allow", "fullscreen; encrypted-media; "
|
|
139
|
+
+ "picture-in-picture");
|
|
140
|
+
// Hide the original anchor and pull it out
|
|
141
|
+
// of tab order. We render a parallel
|
|
142
|
+
// fallback link AFTER the iframe so the
|
|
143
|
+
// user still has a deterministic "open the
|
|
144
|
+
// real URL" anchor without competing for
|
|
145
|
+
// focus with the iframe's contents.
|
|
146
|
+
anchor.setAttribute("hidden", "");
|
|
147
|
+
anchor.setAttribute("tabindex", "-1");
|
|
148
|
+
const fallback = document.createElement("a");
|
|
149
|
+
fallback.className = "cobd-embed-fallback";
|
|
150
|
+
fallback.href = href;
|
|
151
|
+
fallback.target = "_blank";
|
|
152
|
+
fallback.rel = "noopener nofollow";
|
|
153
|
+
fallback.referrerPolicy = "no-referrer";
|
|
154
|
+
fallback.textContent = "Open at " + hostOf(href);
|
|
155
|
+
this.appendChild(iframe);
|
|
156
|
+
this.appendChild(fallback);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (typeof customElements !== "undefined"
|
|
160
|
+
&& !customElements.get("cobd-embed")) {
|
|
161
|
+
customElements.define("cobd-embed", CobdEmbedElement);
|
|
162
|
+
}
|
|
163
|
+
export { CobdEmbedElement };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// <cobd-nav path="..." aria-label="..." lines="inset">
|
|
2
|
+
// -- Light-DOM custom element that renders one of the
|
|
3
|
+
// COBD nav surfaces.
|
|
4
|
+
//
|
|
5
|
+
// `path` accepts three shapes:
|
|
6
|
+
//
|
|
7
|
+
// path="local"
|
|
8
|
+
// Read the element's own <ul>/<li> children as the
|
|
9
|
+
// nav source. Server-rendered HTML works unchanged
|
|
10
|
+
// without JS. Locale labels are not supported in
|
|
11
|
+
// this mode -- the markup is the truth.
|
|
12
|
+
//
|
|
13
|
+
// path="<URL>" (has a scheme or leading /)
|
|
14
|
+
// Fetch the JSON from that URL and render it.
|
|
15
|
+
//
|
|
16
|
+
// path="<slug>" (anything else)
|
|
17
|
+
// Look the slug up via getNav() from
|
|
18
|
+
// clf-core's bundled nav data.
|
|
19
|
+
//
|
|
20
|
+
// Render shape depends on whether Ionic is in use on
|
|
21
|
+
// the page. We detect this by looking for <ion-app>
|
|
22
|
+
// in the DOM at render time (<ion-app> is present in
|
|
23
|
+
// the DOM before <cobd-nav> upgrades in every Ionic
|
|
24
|
+
// page, vanilla or Angular, which sidesteps the
|
|
25
|
+
// custom-elements-not-yet-defined race that a
|
|
26
|
+
// customElements.get("ion-list") check would hit
|
|
27
|
+
// under Angular Ionic's deferred bootstrap):
|
|
28
|
+
//
|
|
29
|
+
// Ionic present -> emit <ion-list> of <ion-item>s
|
|
30
|
+
// (or transform the local <ul>
|
|
31
|
+
// into the same).
|
|
32
|
+
// Ionic absent -> emit a plain <ul> of <li><a>s
|
|
33
|
+
// in slug + URL modes; in local
|
|
34
|
+
// mode the existing <ul> is the
|
|
35
|
+
// source of truth, so the
|
|
36
|
+
// element leaves it alone and
|
|
37
|
+
// just tags the active link.
|
|
38
|
+
//
|
|
39
|
+
// Light DOM (no shadow root) so a surrounding
|
|
40
|
+
// <ion-accordion slot="content"> still slot-routes
|
|
41
|
+
// the rendered list, and so ion-* tags inside the
|
|
42
|
+
// element get upgraded by the page's Ionic instance.
|
|
43
|
+
//
|
|
44
|
+
// Static-HTML fallback: in slug + URL modes the host
|
|
45
|
+
// renders skeleton ion-items (or whatever) as child
|
|
46
|
+
// content; those stay visible until the element
|
|
47
|
+
// upgrades and replaces them. In local mode the
|
|
48
|
+
// <ul>/<li> tree IS the source of truth -- a
|
|
49
|
+
// JS-blocked page sees the same data, just as a
|
|
50
|
+
// plain anchor list.
|
|
51
|
+
// @ts-expect-error -- ../navs.js is generated by
|
|
52
|
+
// build-navs.mjs into dist/ and has no source
|
|
53
|
+
// counterpart in src/ at type-check time. At
|
|
54
|
+
// runtime cobd-nav.js lives at
|
|
55
|
+
// dist/components/cobd-nav.js and navs.js lives
|
|
56
|
+
// at dist/navs.js, so the relative import is one
|
|
57
|
+
// directory up.
|
|
58
|
+
import { getNav } from "../navs.js";
|
|
59
|
+
// `path="local"` is the sentinel that means "read
|
|
60
|
+
// the children." URL form requires either an
|
|
61
|
+
// http(s):// scheme or a leading `/` so a slug
|
|
62
|
+
// like `cobd.ca` is not misread as a hostname.
|
|
63
|
+
function resolveMode(path) {
|
|
64
|
+
if (path === "local")
|
|
65
|
+
return "local";
|
|
66
|
+
if (/^https?:\/\//i.test(path))
|
|
67
|
+
return "url";
|
|
68
|
+
if (path.startsWith("/"))
|
|
69
|
+
return "url";
|
|
70
|
+
return "slug";
|
|
71
|
+
}
|
|
72
|
+
// Sidestep the customElements-not-yet-defined race
|
|
73
|
+
// under Angular by looking for <ion-app> in the DOM
|
|
74
|
+
// rather than checking customElements.get("ion-list").
|
|
75
|
+
// <ion-app> is present pre-bootstrap; ion-list isn't.
|
|
76
|
+
function ionicPresent() {
|
|
77
|
+
return typeof document !== "undefined"
|
|
78
|
+
&& document.querySelector("ion-app") !== null;
|
|
79
|
+
}
|
|
80
|
+
// Normalise a URL string (root-relative, absolute,
|
|
81
|
+
// or with/without trailing slash) so the active-page
|
|
82
|
+
// comparison works regardless of how the nav author
|
|
83
|
+
// wrote the href.
|
|
84
|
+
function norm(href) {
|
|
85
|
+
try {
|
|
86
|
+
const u = new URL(href, location.origin);
|
|
87
|
+
const p = u.pathname.replace(/\/$/, "") || "/";
|
|
88
|
+
return u.origin + p;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ------------------------------------------------------------
|
|
95
|
+
// Local-mode parser: <ul>/<li> -> NavItem[]
|
|
96
|
+
//
|
|
97
|
+
// <li>
|
|
98
|
+
// <ion-icon name="..."></ion-icon>? -- optional
|
|
99
|
+
// <a href="..." [target="_blank"]>Label</a> -- optional;
|
|
100
|
+
// group-only
|
|
101
|
+
// items omit
|
|
102
|
+
// <ul>...</ul> -- optional
|
|
103
|
+
// submenu
|
|
104
|
+
// </li>
|
|
105
|
+
//
|
|
106
|
+
// Items without either an <a> or a textual label are
|
|
107
|
+
// skipped. Author-supplied DOM is treated as the
|
|
108
|
+
// source of truth; we do not validate against the
|
|
109
|
+
// JSON nav schema (different shape, different
|
|
110
|
+
// tradeoffs).
|
|
111
|
+
//
|
|
112
|
+
// Only used when Ionic is present -- the parsed
|
|
113
|
+
// items feed buildIonList. In Ionic-absent mode we
|
|
114
|
+
// leave the <ul>/<li> tree alone entirely.
|
|
115
|
+
// ------------------------------------------------------------
|
|
116
|
+
function parseLocalUL(ul) {
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const li of Array.from(ul.children)) {
|
|
119
|
+
if (li.tagName !== "LI")
|
|
120
|
+
continue;
|
|
121
|
+
const a = li.querySelector(":scope > a");
|
|
122
|
+
const subUl = li.querySelector(":scope > ul");
|
|
123
|
+
const ionIcon = li.querySelector(":scope > ion-icon, :scope > a > ion-icon");
|
|
124
|
+
let label = "";
|
|
125
|
+
if (a) {
|
|
126
|
+
const clone = a.cloneNode(true);
|
|
127
|
+
for (const ic of Array.from(clone.querySelectorAll("ion-icon"))) {
|
|
128
|
+
ic.remove();
|
|
129
|
+
}
|
|
130
|
+
label = clone.textContent?.trim() ?? "";
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
for (const node of Array.from(li.childNodes)) {
|
|
134
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
135
|
+
const t = node.textContent?.trim() ?? "";
|
|
136
|
+
if (t) {
|
|
137
|
+
label = t;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!label)
|
|
144
|
+
continue;
|
|
145
|
+
const item = { label };
|
|
146
|
+
if (a?.getAttribute("href")) {
|
|
147
|
+
item.href = a.getAttribute("href") ?? undefined;
|
|
148
|
+
}
|
|
149
|
+
if (a?.getAttribute("target") === "_blank") {
|
|
150
|
+
item.rel = "external";
|
|
151
|
+
}
|
|
152
|
+
if (ionIcon) {
|
|
153
|
+
const name = ionIcon.getAttribute("name");
|
|
154
|
+
if (name)
|
|
155
|
+
item.icon = name;
|
|
156
|
+
}
|
|
157
|
+
if (subUl) {
|
|
158
|
+
const kids = parseLocalUL(subUl);
|
|
159
|
+
if (kids.length)
|
|
160
|
+
item.items = kids;
|
|
161
|
+
}
|
|
162
|
+
out.push(item);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
// ------------------------------------------------------------
|
|
167
|
+
// Renderer: NavItem[] -> <ion-list> (Ionic-present)
|
|
168
|
+
// ------------------------------------------------------------
|
|
169
|
+
function buildIonList(items, lines, ariaLabel, here) {
|
|
170
|
+
const list = document.createElement("ion-list");
|
|
171
|
+
list.setAttribute("lines", lines);
|
|
172
|
+
if (ariaLabel)
|
|
173
|
+
list.setAttribute("aria-label", ariaLabel);
|
|
174
|
+
for (const item of items) {
|
|
175
|
+
const it = document.createElement("ion-item");
|
|
176
|
+
if (item.href)
|
|
177
|
+
it.setAttribute("href", item.href);
|
|
178
|
+
it.setAttribute("button", "");
|
|
179
|
+
it.setAttribute("detail", "true");
|
|
180
|
+
if (item.rel === "external") {
|
|
181
|
+
it.setAttribute("target", "_blank");
|
|
182
|
+
}
|
|
183
|
+
if (item.href && norm(item.href) === here) {
|
|
184
|
+
it.setAttribute("aria-current", "page");
|
|
185
|
+
it.classList.add("cobd-active-nav-item");
|
|
186
|
+
}
|
|
187
|
+
if (item.icon) {
|
|
188
|
+
const ic = document.createElement("ion-icon");
|
|
189
|
+
ic.setAttribute("slot", "start");
|
|
190
|
+
ic.setAttribute("name", item.icon);
|
|
191
|
+
ic.setAttribute("aria-hidden", "true");
|
|
192
|
+
it.appendChild(ic);
|
|
193
|
+
}
|
|
194
|
+
const lbl = document.createElement("ion-label");
|
|
195
|
+
lbl.textContent = item.label;
|
|
196
|
+
it.appendChild(lbl);
|
|
197
|
+
list.appendChild(it);
|
|
198
|
+
}
|
|
199
|
+
return list;
|
|
200
|
+
}
|
|
201
|
+
// ------------------------------------------------------------
|
|
202
|
+
// Renderer: NavItem[] -> <ul> (Ionic-absent)
|
|
203
|
+
//
|
|
204
|
+
// Plain semantic markup. <ion-icon> tags still emit;
|
|
205
|
+
// they stay as inert unupgraded custom elements when
|
|
206
|
+
// Ionic isn't on the page (zero-width text), and
|
|
207
|
+
// consumers who DO bring ionicons separately get the
|
|
208
|
+
// glyphs for free. Active link gets aria-current +
|
|
209
|
+
// the .cobd-active-nav-item class on the <li>.
|
|
210
|
+
// ------------------------------------------------------------
|
|
211
|
+
function buildPlainUL(items, ariaLabel, here) {
|
|
212
|
+
const ul = document.createElement("ul");
|
|
213
|
+
if (ariaLabel)
|
|
214
|
+
ul.setAttribute("aria-label", ariaLabel);
|
|
215
|
+
for (const item of items) {
|
|
216
|
+
const li = document.createElement("li");
|
|
217
|
+
const isActive = !!item.href
|
|
218
|
+
&& norm(item.href) === here;
|
|
219
|
+
if (isActive)
|
|
220
|
+
li.classList.add("cobd-active-nav-item");
|
|
221
|
+
if (item.href) {
|
|
222
|
+
const a = document.createElement("a");
|
|
223
|
+
a.setAttribute("href", item.href);
|
|
224
|
+
if (item.rel === "external") {
|
|
225
|
+
a.setAttribute("target", "_blank");
|
|
226
|
+
a.setAttribute("rel", "noopener noreferrer");
|
|
227
|
+
}
|
|
228
|
+
if (isActive)
|
|
229
|
+
a.setAttribute("aria-current", "page");
|
|
230
|
+
if (item.icon) {
|
|
231
|
+
const ic = document.createElement("ion-icon");
|
|
232
|
+
ic.setAttribute("name", item.icon);
|
|
233
|
+
ic.setAttribute("aria-hidden", "true");
|
|
234
|
+
a.appendChild(ic);
|
|
235
|
+
}
|
|
236
|
+
a.appendChild(document.createTextNode(item.label));
|
|
237
|
+
li.appendChild(a);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
li.appendChild(document.createTextNode(item.label));
|
|
241
|
+
}
|
|
242
|
+
if (item.items?.length) {
|
|
243
|
+
li.appendChild(buildPlainUL(item.items, null, here));
|
|
244
|
+
}
|
|
245
|
+
ul.appendChild(li);
|
|
246
|
+
}
|
|
247
|
+
return ul;
|
|
248
|
+
}
|
|
249
|
+
// ------------------------------------------------------------
|
|
250
|
+
// In-place active marker for local + !ionic
|
|
251
|
+
//
|
|
252
|
+
// The author's <ul>/<li> tree IS the rendered output;
|
|
253
|
+
// we just walk the existing anchors and stamp
|
|
254
|
+
// aria-current="page" on whichever matches the
|
|
255
|
+
// current page. Idempotent so re-renders are safe.
|
|
256
|
+
// ------------------------------------------------------------
|
|
257
|
+
function markActiveInPlace(root, here) {
|
|
258
|
+
for (const a of Array.from(root.querySelectorAll("a"))) {
|
|
259
|
+
const href = a.getAttribute("href");
|
|
260
|
+
const wasActive = a.getAttribute("aria-current") === "page";
|
|
261
|
+
const isActive = !!href && norm(href) === here;
|
|
262
|
+
if (isActive) {
|
|
263
|
+
a.setAttribute("aria-current", "page");
|
|
264
|
+
a.closest("li")?.classList.add("cobd-active-nav-item");
|
|
265
|
+
}
|
|
266
|
+
else if (wasActive) {
|
|
267
|
+
a.removeAttribute("aria-current");
|
|
268
|
+
a.closest("li")?.classList.remove("cobd-active-nav-item");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Light-DOM elements get `display: inline` by
|
|
273
|
+
// default, which would collapse the contained list
|
|
274
|
+
// to zero height. Inject one global rule the first
|
|
275
|
+
// time the element upgrades; idempotent so each
|
|
276
|
+
// page only pays for it once.
|
|
277
|
+
function ensureBaseStyle() {
|
|
278
|
+
if (typeof document === "undefined")
|
|
279
|
+
return;
|
|
280
|
+
const id = "cobd-nav-base-style";
|
|
281
|
+
if (document.getElementById(id))
|
|
282
|
+
return;
|
|
283
|
+
const style = document.createElement("style");
|
|
284
|
+
style.id = id;
|
|
285
|
+
style.textContent = "cobd-nav { display: block; }";
|
|
286
|
+
document.head.appendChild(style);
|
|
287
|
+
}
|
|
288
|
+
class CobdNav extends HTMLElement {
|
|
289
|
+
constructor() {
|
|
290
|
+
super(...arguments);
|
|
291
|
+
this.rendered = false;
|
|
292
|
+
}
|
|
293
|
+
static get observedAttributes() {
|
|
294
|
+
return ["path", "lines", "aria-label", "locale"];
|
|
295
|
+
}
|
|
296
|
+
connectedCallback() {
|
|
297
|
+
ensureBaseStyle();
|
|
298
|
+
if (!this.rendered)
|
|
299
|
+
this.render();
|
|
300
|
+
}
|
|
301
|
+
attributeChangedCallback() {
|
|
302
|
+
if (this.isConnected && this.rendered)
|
|
303
|
+
this.render();
|
|
304
|
+
}
|
|
305
|
+
async render() {
|
|
306
|
+
const path = this.getAttribute("path");
|
|
307
|
+
if (!path)
|
|
308
|
+
return;
|
|
309
|
+
const mode = resolveMode(path);
|
|
310
|
+
const lines = this.getAttribute("lines") ?? "inset";
|
|
311
|
+
const ariaLabel = this.getAttribute("aria-label");
|
|
312
|
+
const locale = this.getAttribute("locale") ?? undefined;
|
|
313
|
+
const useIonic = ionicPresent();
|
|
314
|
+
const here = norm(location.href);
|
|
315
|
+
// Local + !ionic: the markup IS the rendered
|
|
316
|
+
// output. Just tag active links and exit.
|
|
317
|
+
if (mode === "local" && !useIonic) {
|
|
318
|
+
markActiveInPlace(this, here);
|
|
319
|
+
this.rendered = true;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
let items = null;
|
|
323
|
+
try {
|
|
324
|
+
if (mode === "local") {
|
|
325
|
+
const ul = this.querySelector(":scope > ul");
|
|
326
|
+
if (!ul) {
|
|
327
|
+
console.error("cobd-nav: path=\"local\""
|
|
328
|
+
+ " but no child <ul> found");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
items = parseLocalUL(ul);
|
|
332
|
+
}
|
|
333
|
+
else if (mode === "url") {
|
|
334
|
+
const res = await fetch(path, {
|
|
335
|
+
cache: "no-cache"
|
|
336
|
+
});
|
|
337
|
+
if (!res.ok) {
|
|
338
|
+
throw new Error("http " + res.status);
|
|
339
|
+
}
|
|
340
|
+
const nav = await res.json();
|
|
341
|
+
items = applyLocale(nav.items, locale);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const nav = getNav(path, locale);
|
|
345
|
+
items = nav.items;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
console.error("cobd-nav:", err);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!items)
|
|
353
|
+
return;
|
|
354
|
+
const tree = useIonic
|
|
355
|
+
? buildIonList(items, lines, ariaLabel, here)
|
|
356
|
+
: buildPlainUL(items, ariaLabel, here);
|
|
357
|
+
this.replaceChildren(tree);
|
|
358
|
+
this.rendered = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Apply label_i18n overrides for URL mode. getNav()
|
|
362
|
+
// already does this internally; here we apply the
|
|
363
|
+
// same shape to remote JSON before render so the
|
|
364
|
+
// two paths behave the same.
|
|
365
|
+
function applyLocale(items, locale) {
|
|
366
|
+
if (!locale)
|
|
367
|
+
return items;
|
|
368
|
+
return items.map(it => {
|
|
369
|
+
const i18n = it.label_i18n;
|
|
370
|
+
const next = { ...it };
|
|
371
|
+
delete next.label_i18n;
|
|
372
|
+
if (i18n && i18n[locale]) {
|
|
373
|
+
next.label = i18n[locale];
|
|
374
|
+
}
|
|
375
|
+
if (next.items) {
|
|
376
|
+
next.items = applyLocale(next.items, locale);
|
|
377
|
+
}
|
|
378
|
+
return next;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (!customElements.get("cobd-nav")) {
|
|
382
|
+
customElements.define("cobd-nav", CobdNav);
|
|
383
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface SupportAction {
|
|
2
|
+
label: string;
|
|
3
|
+
icon?: string;
|
|
4
|
+
href?: string;
|
|
5
|
+
target?: string;
|
|
6
|
+
share?: boolean;
|
|
7
|
+
}
|
|
8
|
+
declare function ionicPresent(): boolean;
|
|
9
|
+
declare const BASE_STYLE_ID = "cobd-support-base-style";
|
|
10
|
+
declare const BASE_STYLE = "\ncobd-support { display: inline-block; }\n.cobd-support-menu {\n border: 1px solid var(--cobd-color-medium, #92949c);\n border-radius: var(--cobd-radius-md, 8px);\n padding: var(--cobd-spacing-xs, 4px);\n background: var(--cobd-color-background, #fff);\n color: var(--cobd-color-foreground, #1a1a1a);\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);\n min-width: 14rem;\n margin: 0;\n}\n.cobd-support-menu-item {\n display: flex;\n align-items: center;\n gap: var(--cobd-spacing-sm, 8px);\n padding: var(--cobd-spacing-sm, 8px) var(--cobd-spacing-md, 16px);\n color: inherit;\n background: none;\n border: 0;\n border-radius: var(--cobd-radius-sm, 4px);\n text-decoration: none;\n font: inherit;\n text-align: left;\n width: 100%;\n cursor: pointer;\n}\n.cobd-support-menu-item:hover,\n.cobd-support-menu-item:focus-visible {\n background: var(--cobd-color-light, #f4f5f8);\n outline: 2px solid var(--cobd-color-primary, #72cadb);\n outline-offset: -2px;\n}\n.cobd-support-button {\n font-family: var(--cobd-typography-family-sans,\n system-ui, sans-serif);\n font-size: var(--cobd-typography-size-md, 1rem);\n font-weight: var(--cobd-typography-weight-medium, 500);\n padding: var(--cobd-spacing-sm, 8px) var(--cobd-spacing-md, 16px);\n border-radius: var(--cobd-radius-sm, 4px);\n border: 0;\n background: var(--cobd-color-primary, #72cadb);\n color: var(--cobd-color-primary-contrast, #000);\n cursor: pointer;\n}\n.cobd-support-button:hover {\n background: var(--cobd-color-primary-strong,\n var(--cobd-color-primary, #72cadb));\n}\n";
|
|
11
|
+
declare function ensureBaseStyle(): void;
|
|
12
|
+
declare function readCustomActions(host: HTMLElement): SupportAction[] | null;
|
|
13
|
+
declare class CobdSupport extends HTMLElement {
|
|
14
|
+
static get observedAttributes(): string[];
|
|
15
|
+
private rendered;
|
|
16
|
+
private customActions;
|
|
17
|
+
connectedCallback(): void;
|
|
18
|
+
attributeChangedCallback(): void;
|
|
19
|
+
private render;
|
|
20
|
+
private defaultActions;
|
|
21
|
+
private renderIonic;
|
|
22
|
+
private openIonicSheet;
|
|
23
|
+
private renderPlain;
|
|
24
|
+
private buildPlainItem;
|
|
25
|
+
private handleAction;
|
|
26
|
+
}
|