sol-components 2.1.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.
Files changed (150) hide show
  1. package/README.md +7 -0
  2. package/core/activate.js +27 -0
  3. package/core/adopt.js +71 -0
  4. package/core/auth-core.js +73 -0
  5. package/core/auth-fetch.js +154 -0
  6. package/core/component-mount.js +110 -0
  7. package/core/defaults.js +48 -0
  8. package/core/define.js +15 -0
  9. package/core/display-target.js +166 -0
  10. package/core/edit-placements.js +28 -0
  11. package/core/editor-self.js +127 -0
  12. package/core/editor.js +162 -0
  13. package/core/events.js +27 -0
  14. package/core/extension-points.js +189 -0
  15. package/core/form-utils.js +210 -0
  16. package/core/from-query.js +138 -0
  17. package/core/from-rdf.js +52 -0
  18. package/core/here.js +33 -0
  19. package/core/include-core.js +73 -0
  20. package/core/inrupt-global.js +18 -0
  21. package/core/menu-consumer.js +41 -0
  22. package/core/menu-rdf.js +154 -0
  23. package/core/pod-ops.js +392 -0
  24. package/core/pod-registry.js +82 -0
  25. package/core/popup-proxy.js +255 -0
  26. package/core/rdf-core.js +280 -0
  27. package/core/rdf-render.js +136 -0
  28. package/core/rdf-utils.js +411 -0
  29. package/core/rdf.js +154 -0
  30. package/core/services.js +106 -0
  31. package/core/shape-to-form.js +741 -0
  32. package/core/sparql-safety.js +20 -0
  33. package/core/utils.js +196 -0
  34. package/dist/importmap-cdn.json +49 -0
  35. package/dist/importmap-local.json +49 -0
  36. package/dist/sol-loader.manifest.json +140 -0
  37. package/dist/vendor/@comunica-query-sparql.js +137851 -0
  38. package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
  39. package/dist/vendor/dompurify.js +1476 -0
  40. package/dist/vendor/ical.js.js +9739 -0
  41. package/dist/vendor/marked.js +85 -0
  42. package/dist/vendor/n3.js +14670 -0
  43. package/dist/vendor/rdf-validate-shacl.js +6970 -0
  44. package/dist/vendor/rdflib.js +35172 -0
  45. package/dist/vendor/solid-logic.js +6819 -0
  46. package/dist/vendor/solid-ui.js +21945 -0
  47. package/node/sol-form.js +133 -0
  48. package/node/sol-include.js +55 -0
  49. package/node/sol-login.js +632 -0
  50. package/node/sol-menu.js +639 -0
  51. package/node/sol-query.js +116 -0
  52. package/package.json +133 -0
  53. package/web/menu-from-rdf.js +23 -0
  54. package/web/scripts/prefs.js +25 -0
  55. package/web/sol-accordion.js +114 -0
  56. package/web/sol-basic.js +50 -0
  57. package/web/sol-breadcrumb.js +131 -0
  58. package/web/sol-button.js +244 -0
  59. package/web/sol-calendar.js +465 -0
  60. package/web/sol-default.js +118 -0
  61. package/web/sol-dropdown-button.js +222 -0
  62. package/web/sol-feed.js +1336 -0
  63. package/web/sol-form.js +949 -0
  64. package/web/sol-full.js +43 -0
  65. package/web/sol-gallery.js +303 -0
  66. package/web/sol-include.js +246 -0
  67. package/web/sol-live-edit.js +415 -0
  68. package/web/sol-login.js +856 -0
  69. package/web/sol-menu.js +593 -0
  70. package/web/sol-modal.js +377 -0
  71. package/web/sol-pod-extras.js +17 -0
  72. package/web/sol-pod-ops.js +680 -0
  73. package/web/sol-pod.js +1039 -0
  74. package/web/sol-query.js +546 -0
  75. package/web/sol-rolodex.js +95 -0
  76. package/web/sol-search.js +402 -0
  77. package/web/sol-settings.js +199 -0
  78. package/web/sol-solidos.js +93 -0
  79. package/web/sol-tabs.js +445 -0
  80. package/web/sol-time.js +194 -0
  81. package/web/sol-tree-edit.js +492 -0
  82. package/web/sol-wac.js +456 -0
  83. package/web/sol-weather.js +337 -0
  84. package/web/sol-window.js +142 -0
  85. package/web/styles/buttons-css.js +108 -0
  86. package/web/styles/help.css +242 -0
  87. package/web/styles/root.css +112 -0
  88. package/web/styles/sol-accordion-css.js +97 -0
  89. package/web/styles/sol-calendar-css.js +154 -0
  90. package/web/styles/sol-feed-css.js +475 -0
  91. package/web/styles/sol-form-css.js +471 -0
  92. package/web/styles/sol-gallery-css.js +181 -0
  93. package/web/styles/sol-include-css.js +95 -0
  94. package/web/styles/sol-live-edit-css.js +84 -0
  95. package/web/styles/sol-live-edit.css +101 -0
  96. package/web/styles/sol-login-css.js +116 -0
  97. package/web/styles/sol-menu-css.js +145 -0
  98. package/web/styles/sol-modal-css.js +134 -0
  99. package/web/styles/sol-pod-css.js +187 -0
  100. package/web/styles/sol-pod-modal-css.js +203 -0
  101. package/web/styles/sol-query-css.js +140 -0
  102. package/web/styles/sol-query-help.css +267 -0
  103. package/web/styles/sol-query-one-pager.css +67 -0
  104. package/web/styles/sol-search-css.js +157 -0
  105. package/web/styles/sol-solidos-css.js +7 -0
  106. package/web/styles/sol-tabs-css.js +114 -0
  107. package/web/styles/sol-time-css.js +30 -0
  108. package/web/styles/sol-wac-css.js +73 -0
  109. package/web/styles/sol-weather-css.js +59 -0
  110. package/web/styles/solid-logo.svg +9 -0
  111. package/web/styles/view-accordion-css.js +66 -0
  112. package/web/styles/view-anchorlist-css.js +22 -0
  113. package/web/styles/view-autocomplete-css.js +59 -0
  114. package/web/styles/view-rolodex-css.js +102 -0
  115. package/web/styles/view-select-css.js +21 -0
  116. package/web/utils/calendar-fetch.js +388 -0
  117. package/web/utils/code-mirror-editor.js +82 -0
  118. package/web/utils/commons-fetch.js +108 -0
  119. package/web/utils/feed-edit.js +159 -0
  120. package/web/utils/feed-edit.smoke.mjs +74 -0
  121. package/web/utils/feed-fetch.js +573 -0
  122. package/web/utils/live-edit-help/csv.js +64 -0
  123. package/web/utils/live-edit-help/graphviz.js +41 -0
  124. package/web/utils/live-edit-help/jsonld.js +55 -0
  125. package/web/utils/live-edit-help/markdown.js +52 -0
  126. package/web/utils/live-edit-help/mermaid.js +48 -0
  127. package/web/utils/live-edit-help/turtle.js +85 -0
  128. package/web/utils/rdf-config.js +125 -0
  129. package/web/utils/renderers/csv.js +124 -0
  130. package/web/utils/renderers/d3-force.js +82 -0
  131. package/web/utils/renderers/graphviz.js +13 -0
  132. package/web/utils/renderers/html.js +10 -0
  133. package/web/utils/renderers/jsonld.js +63 -0
  134. package/web/utils/renderers/markdown.js +19 -0
  135. package/web/utils/renderers/mermaid.js +54 -0
  136. package/web/utils/renderers/turtle.js +51 -0
  137. package/web/utils/sol-query-triple-patterns.js +151 -0
  138. package/web/utils/sol-query-ui.js +250 -0
  139. package/web/utils/sol-query-views.js +32 -0
  140. package/web/views/_helpers.js +34 -0
  141. package/web/views/accordion.js +133 -0
  142. package/web/views/anchorlist.js +59 -0
  143. package/web/views/auto-complete.js +183 -0
  144. package/web/views/dl.js +38 -0
  145. package/web/views/list.js +19 -0
  146. package/web/views/menu.js +56 -0
  147. package/web/views/rolodex.js +126 -0
  148. package/web/views/select.js +79 -0
  149. package/web/views/table.js +73 -0
  150. package/web/views/tabs.js +57 -0
@@ -0,0 +1,593 @@
1
+ /**
2
+ * <sol-menu> — Sidebar navigation + content panel.
3
+ *
4
+ * Shadow-DOM element with the same declarative API as <sol-tabs>: fill
5
+ * with <a href="…">Label</a> children, each anchor becomes a menu entry;
6
+ * clicking loads its URL into the content panel.
7
+ *
8
+ * Imperative usage:
9
+ * const m = document.createElement('sol-menu');
10
+ * m.items = [
11
+ * { name: 'Overview', render(body) { ... } },
12
+ * { name: 'Details', render(body) { ... } },
13
+ * ];
14
+ * parent.appendChild(m);
15
+ * m.select('Overview');
16
+ *
17
+ * Declarative usage: like <sol-tabs>. Handler lookup per anchor, falling
18
+ * back to <sol-menu>'s `handler` attribute, then to <sol-include>. The
19
+ * href is forwarded as both `source` and `endpoint`, and other anchor
20
+ * attributes pass through.
21
+ *
22
+ * <sol-menu>
23
+ * <a href="intro.md">Intro</a>
24
+ * <a href="data.ttl" handler="sol-query" pattern="?s ?p ?o">Triples</a>
25
+ * </sol-menu>
26
+ *
27
+ * Submenus: nest <submenu> elements to create collapsible groups. The
28
+ * <label> text is the group heading; anchors (or further <submenu>s) inside
29
+ * become the group's items. Any depth is supported.
30
+ *
31
+ * <sol-menu>
32
+ * <a href="home.md">Home</a>
33
+ * <submenu>
34
+ * <label>Docs</label>
35
+ * <a href="quickstart.md">Quickstart</a>
36
+ * <submenu>
37
+ * <label>API</label>
38
+ * <a href="api/query.md">Query</a>
39
+ * <a href="api/modal.md">Modal</a>
40
+ * </submenu>
41
+ * </submenu>
42
+ * </sol-menu>
43
+ *
44
+ * Attributes:
45
+ * orientation="horizontal" — lay the nav bar on top instead of the side
46
+ * handler="sol-*" — default component for rendering each item
47
+ * from-rdf="menu.ttl#Name" — build the menu from a ui:Menu RDF document
48
+ * instead of light-DOM children. OPT-IN: inert
49
+ * until `web/menu-from-rdf.js` is imported (the
50
+ * lone rdflib pull); the declarative path above
51
+ * needs no rdflib.
52
+ *
53
+ * Events (bubbling, composed):
54
+ * sol-menu-change — detail: { name }
55
+ */
56
+
57
+ import { define } from '../core/define.js';
58
+ import { adopt } from '../core/adopt.js';
59
+ import { attachEditorSelfGear } from '../core/editor-self.js';
60
+ import { CSS as MENU_CSS, sheet as menuSheet } from './styles/sol-menu-css.js';
61
+ import { registerMenuConsumer, deferUntilLoader } from '../core/menu-consumer.js';
62
+ import { renderComponentItem, renderLinkItem, ensureHandler, isCommandName, paramsToObject, dispatchCommand } from '../core/rdf-render.js';
63
+
64
+ /**
65
+ * Sidebar navigation + content panel.
66
+ *
67
+ * Shadow-DOM element. Same declarative API as sol-tabs: fill with anchor
68
+ * children, each becomes a menu entry.
69
+ *
70
+ * @class SolMenu
71
+ * @extends HTMLElement
72
+ * @attr {string} orientation - "horizontal" to lay nav on top (default: sidebar)
73
+ * @attr {string} handler - default sol-* component tag for anchors
74
+ * @fires sol-menu-change - detail: { name }
75
+ *
76
+ * CSS Shadow Parts (outside theming hooks):
77
+ * - `nav` — the .sol-menu-nav strip (the buttons row / column).
78
+ *
79
+ * Content area: the `.sol-menu-content` body where a selection mounts is a
80
+ * LIGHT-DOM child of <sol-menu> (projected through the shadow slot), so
81
+ * results are reachable by page CSS / document queries. It is NOT a shadow
82
+ * part — style it directly, e.g. `sol-menu > .sol-menu-content { overflow: auto }`.
83
+ * Default is `overflow: hidden` (app chrome doesn't scroll; components
84
+ * inside scroll on their own). Authors may supply their own
85
+ * `.sol-menu-content` child; otherwise one is created.
86
+ *
87
+ * Horizontal-orientation nav now wraps (`flex-wrap: wrap`) instead of
88
+ * showing a horizontal scrollbar — items overflow to a second row when
89
+ * they don't fit the chrome width (e.g. large font).
90
+ */
91
+ class SolMenu extends HTMLElement {
92
+ constructor() {
93
+ super();
94
+ this.attachShadow({ mode: 'open' });
95
+ this._items = [];
96
+ this._btns = {};
97
+ this._active = null;
98
+ this._cleanup = null;
99
+ this._rendered = false;
100
+ }
101
+
102
+ static get observedAttributes() { return ['from-rdf']; }
103
+
104
+ // `from-rdf` rendering is an opt-in capability: importing `web/menu-from-rdf.js`
105
+ // installs the rdflib-backed loader here (inherited by SolMenu subclasses such
106
+ // as sol-dropdown-button). Null → declarative-only, no rdflib (see
107
+ // core/menu-consumer.js).
108
+ static fromRdfLoader = null;
109
+
110
+ /** Editor declaration consumed by core/editor.js. Menus are edited
111
+ * with sol-tree-edit (head fields + per-item shapes + drill into
112
+ * nested ui:Menu submenus), so `<sol-form>` is not the right tool. */
113
+ static get editor() {
114
+ return {
115
+ tag: 'sol-tree-edit',
116
+ subjectAttr: 'root',
117
+ attrs: {
118
+ 'head-shape': new URL('../shapes/menu.shacl', import.meta.url).href,
119
+ 'item-shape': new URL('../shapes/menu.shacl', import.meta.url).href,
120
+ 'drill-when-type': 'http://www.w3.org/ns/ui#Menu',
121
+ 'head-label': 'Menu Heading',
122
+ 'items-label': 'menu items',
123
+ },
124
+ };
125
+ }
126
+
127
+ attributeChangedCallback(name, oldValue, newValue) {
128
+ if (name === 'from-rdf' && oldValue !== newValue && this._rendered) {
129
+ this._loadFromRdf(newValue);
130
+ }
131
+ }
132
+
133
+ async connectedCallback() {
134
+ if (this._rendered) return;
135
+
136
+ const fromRdf = this.getAttribute('from-rdf');
137
+ if (fromRdf) {
138
+ this._initShell();
139
+ this._loadFromRdf(fromRdf);
140
+ } else {
141
+ const declared = this._items.length === 0 ? this._harvestItems(this) : null;
142
+ this._initShell();
143
+ if (declared?.length) this._items = declared;
144
+ this._renderNav();
145
+ this._autoSelectFirst();
146
+ }
147
+
148
+ if (this.hasAttribute('editor-self')) attachEditorSelfGear(this);
149
+ }
150
+
151
+ // Select the first leaf so the content panel isn't empty on load. Overridable
152
+ // — e.g. <sol-dropdown-button> has no panel and shouldn't pre-fire anything.
153
+ _autoSelectFirst() {
154
+ const firstLeaf = this._firstLeaf(this._items);
155
+ if (firstLeaf) this.select(firstLeaf.name);
156
+ }
157
+
158
+ _initShell() {
159
+ const orient = this.getAttribute('orientation') === 'horizontal' ? 'horizontal' : 'vertical';
160
+ const root = this.shadowRoot;
161
+ root.innerHTML = `
162
+ <div class="sol-menu-nav" part="nav" role="menubar" aria-orientation="${orient}"></div>
163
+ <slot></slot>`;
164
+ adopt(root, { sheet: menuSheet, css: MENU_CSS });
165
+ // Content area lives in LIGHT DOM (projected through the slot) so
166
+ // menu-click results are reachable by page CSS / document queries. The
167
+ // author may supply their own `.sol-menu-content` child; else create one.
168
+ if (!this.querySelector(':scope > .sol-menu-content')) {
169
+ const content = document.createElement('div');
170
+ content.className = 'sol-menu-content';
171
+ content.setAttribute('role', 'region');
172
+ this.appendChild(content);
173
+ }
174
+ this._rendered = true;
175
+ this._onDocClick = (e) => {
176
+ if (!this.contains(e.target) && !root.contains(e.target)) this._closeAllPopups();
177
+ };
178
+ document.addEventListener('click', this._onDocClick);
179
+ this._onKeyDown = (e) => this._handleKeyDown(e);
180
+ root.addEventListener('keydown', this._onKeyDown);
181
+
182
+ // Sync active-state visuals when something else (e.g. <sol-button>)
183
+ // mounts a non-menu tab into our linkTarget. The mount layer
184
+ // dispatches sol-tab-activate; if the name isn't one of our items,
185
+ // we clear every active button so the chrome doesn't pretend the
186
+ // user is still "on" a menu page.
187
+ this._onTabActivate = (e) => {
188
+ const name = e.detail?.name;
189
+ const isOurs = name && this._flatLeaves(this._items).some(i => i.name === name);
190
+ if (isOurs) {
191
+ if (this._active !== name) {
192
+ this._active = name;
193
+ this._setActiveButton(name);
194
+ }
195
+ } else {
196
+ this._active = null;
197
+ this._setActiveButton(null);
198
+ }
199
+ };
200
+ document.addEventListener('sol-tab-activate', this._onTabActivate);
201
+ }
202
+
203
+ _handleKeyDown(e) {
204
+ const root = this.shadowRoot;
205
+ const nav = root.querySelector('.sol-menu-nav');
206
+ if (!nav) return;
207
+ const horizontal = this.getAttribute('orientation') === 'horizontal';
208
+
209
+ // Escape closes any open popup
210
+ if (e.key === 'Escape') {
211
+ const openGroup = root.querySelector('.sol-menu-group.open');
212
+ if (openGroup) {
213
+ this._closeAllPopups();
214
+ const groupBtn = openGroup.querySelector(':scope > .sol-menu-group-btn');
215
+ if (groupBtn) groupBtn.focus();
216
+ e.preventDefault();
217
+ return;
218
+ }
219
+ }
220
+
221
+ // Arrow / Home / End navigation among focusable buttons in the nav
222
+ const nextKey = horizontal ? 'ArrowRight' : 'ArrowDown';
223
+ const prevKey = horizontal ? 'ArrowLeft' : 'ArrowUp';
224
+ if (![nextKey, prevKey, 'Home', 'End'].includes(e.key)) return;
225
+ if (!nav.contains(e.target) || e.target.tagName !== 'BUTTON') return;
226
+
227
+ // Collect focusable buttons visible at the current level
228
+ const focusable = this._focusableButtons(nav, e.target);
229
+ if (!focusable.length) return;
230
+ const idx = focusable.indexOf(e.target);
231
+ let next;
232
+ if (e.key === nextKey) next = focusable[(idx + 1) % focusable.length];
233
+ if (e.key === prevKey) next = focusable[(idx - 1 + focusable.length) % focusable.length];
234
+ if (e.key === 'Home') next = focusable[0];
235
+ if (e.key === 'End') next = focusable[focusable.length - 1];
236
+ if (next && next !== e.target) {
237
+ this._setRovingFocus(next);
238
+ e.preventDefault();
239
+ }
240
+ }
241
+
242
+ _focusableButtons(container, target) {
243
+ // If target is inside an open popup, scope to that popup; otherwise top-level nav
244
+ const popup = target.closest('.sol-menu-popup');
245
+ const scope = popup || container;
246
+ return Array.from(scope.querySelectorAll(':scope > button, :scope > .sol-menu-group > .sol-menu-group-btn'));
247
+ }
248
+
249
+ _setRovingFocus(btn) {
250
+ const nav = this.shadowRoot.querySelector('.sol-menu-nav');
251
+ if (!nav) return;
252
+ nav.querySelectorAll('button').forEach(b => b.setAttribute('tabindex', '-1'));
253
+ btn.setAttribute('tabindex', '0');
254
+ btn.focus();
255
+ }
256
+
257
+ async _loadFromRdf(uri) {
258
+ const load = this.constructor.fromRdfLoader;
259
+ if (!load) { deferUntilLoader(this); return; } // wait for the menu-from-rdf add-on
260
+ try {
261
+ const result = await load(uri, document.baseURI);
262
+ if (!result) return;
263
+ if (!this.hasAttribute('orientation')) this.setAttribute('orientation', result.orientation);
264
+ this._items = this._wrapRdfItems(result.items);
265
+ this._renderNav();
266
+ this._autoSelectFirst();
267
+ } catch (err) {
268
+ console.error('<sol-menu> from-rdf load failed:', err);
269
+ this.dispatchEvent(new CustomEvent('sol-error', {
270
+ bubbles: true, composed: true,
271
+ detail: { source: 'sol-menu', kind: 'rdf-load', uri, message: err.message },
272
+ }));
273
+ }
274
+ }
275
+
276
+ // Wrap pure item descriptions from core/menu-rdf.js with the DOM-side
277
+ // render closures the rest of the component expects. The leaf closures
278
+ // are built by core/rdf-render.js, shared with <sol-tabs>.
279
+ _wrapRdfItems(descriptions) {
280
+ const ctx = {
281
+ host: this, baseUrl: import.meta.url,
282
+ sourceName: 'sol-menu', embedClass: 'sol-menu-embed',
283
+ };
284
+ return descriptions.map(desc => {
285
+ const rw = desc.requiresWrite; // surfaced as part="requires-write"; app decides policy
286
+ if (desc.type === 'submenu') {
287
+ return { name: desc.name, requiresWrite: rw, children: this._wrapRdfItems(desc.children) };
288
+ }
289
+ if (desc.type === 'component') {
290
+ // A ui:Component whose ui:name isn't a custom-element tag is a command:
291
+ // clicking dispatches sol-command (no content mounted, not selectable).
292
+ if (isCommandName(desc.tag)) {
293
+ return { name: desc.name, icon: desc.icon, requiresWrite: rw, command: desc.tag, params: paramsToObject(desc.params) };
294
+ }
295
+ return { name: desc.name, icon: desc.icon, requiresWrite: rw, render: renderComponentItem(desc, ctx) };
296
+ }
297
+ return { name: desc.name, icon: desc.icon, requiresWrite: rw, render: renderLinkItem(desc, ctx) };
298
+ });
299
+ }
300
+
301
+ _harvestItems(root) {
302
+ const parentHandler = (this.getAttribute('handler') || '').trim();
303
+ const SKIP = new Set(['href', 'handler', 'params', 'requires-write', 'if-logged-in', 'icon',
304
+ 'target', 'rel', 'download', 'hreflang', 'type', 'referrerpolicy']);
305
+ // A menu item is owner-gated by `requires-write` (≙ acl:mode acl:Write) or
306
+ // the friendlier `if-logged-in` boolean — same meaning, surfaced as
307
+ // part="requires-write" for the host to hide. (Whole-button gating is the
308
+ // `if-logged-in` attribute on the launcher itself, handled by host CSS.)
309
+ const isGated = (n) => n.hasAttribute('requires-write') || n.hasAttribute('if-logged-in');
310
+ const out = [];
311
+ let i = 0;
312
+ // A <menu> child is the canonical items container (a dropdown almost always
313
+ // has one); without it we fall back to harvesting loose children. <li>
314
+ // wrappers (the strictly-valid form) are unwrapped to the item element.
315
+ const container = root.querySelector(':scope > menu') || root;
316
+ const nodes = Array.from(container.children)
317
+ .flatMap(n => n.tagName === 'LI' ? Array.from(n.children) : [n]);
318
+ for (const node of nodes) {
319
+ const handler = node.getAttribute('handler');
320
+ if (handler && isCommandName(handler)) {
321
+ // An action item: `handler` is a bare name (not a custom element), so it
322
+ // dispatches sol-command (no content mounted), gated by requires-write
323
+ // (→ part="requires-write") just like the RDF form.
324
+ const label = (node.textContent || '').trim() || `Item ${++i}`;
325
+ const raw = node.getAttribute('params');
326
+ let params;
327
+ if (raw != null) { try { params = JSON.parse(raw); } catch { params = raw; } }
328
+ out.push({
329
+ name: label,
330
+ command: handler,
331
+ params,
332
+ requiresWrite: isGated(node),
333
+ icon: node.getAttribute('icon') || undefined,
334
+ });
335
+ } else if (node.tagName === 'A' && node.hasAttribute('href')) {
336
+ const label = (node.textContent || '').trim() || `Item ${++i}`;
337
+ const url = node.getAttribute('href');
338
+ const handlerTag = (node.getAttribute('handler') || parentHandler || 'sol-include').trim();
339
+ out.push({
340
+ name: label,
341
+ requiresWrite: isGated(node),
342
+ render: (body) => {
343
+ ensureHandler(handlerTag, this, import.meta.url, 'sol-menu');
344
+ const el = document.createElement(handlerTag);
345
+ el.setAttribute('source', url);
346
+ el.setAttribute('endpoint', url);
347
+ for (const attr of node.attributes) {
348
+ if (SKIP.has(attr.name)) continue;
349
+ el.setAttribute(attr.name, attr.value);
350
+ }
351
+ el.classList.add('sol-menu-embed');
352
+ body.appendChild(el);
353
+ },
354
+ });
355
+ } else if (node.tagName === 'SUBMENU') {
356
+ const labelEl = node.querySelector(':scope > label');
357
+ const label = (labelEl?.textContent || '').trim() || `Group ${++i}`;
358
+ const inner = document.createElement('div');
359
+ for (const c of Array.from(node.children)) {
360
+ if (c.tagName === 'LABEL') continue;
361
+ inner.appendChild(c);
362
+ }
363
+ const children = this._harvestItems(inner);
364
+ out.push({ name: label, open: node.hasAttribute('open'), children });
365
+ }
366
+ }
367
+ return out;
368
+ }
369
+
370
+ _firstLeaf(items) {
371
+ for (const it of items) {
372
+ if (it.children) {
373
+ const leaf = this._firstLeaf(it.children);
374
+ if (leaf) return leaf;
375
+ } else if (typeof it.render === 'function') {
376
+ return it;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ _flatLeaves(items, acc = []) {
383
+ for (const it of items) {
384
+ if (it.children) this._flatLeaves(it.children, acc);
385
+ else if (typeof it.render === 'function') acc.push(it);
386
+ }
387
+ return acc;
388
+ }
389
+
390
+ // Command items (no render closure — they dispatch sol-command on click).
391
+ _flatCommands(items, acc = []) {
392
+ for (const it of items) {
393
+ if (it.children) this._flatCommands(it.children, acc);
394
+ else if (it.command) acc.push(it);
395
+ }
396
+ return acc;
397
+ }
398
+
399
+ get items() { return this._items; }
400
+ set items(arr) {
401
+ this._items = arr || [];
402
+ if (this._rendered) this._renderNav();
403
+ }
404
+
405
+ get activeItem() { return this._active; }
406
+ get body() { return this.querySelector(':scope > .sol-menu-content'); }
407
+
408
+ _renderNav() {
409
+ const root = this.shadowRoot;
410
+ const nav = root.querySelector('.sol-menu-nav');
411
+ if (!nav) return;
412
+ nav.innerHTML = '';
413
+ this._btns = {};
414
+ const orient = this.getAttribute('orientation') === 'horizontal' ? 'horizontal' : 'vertical';
415
+ nav.setAttribute('aria-orientation', orient);
416
+ const leafCount = this._flatLeaves(this._items).length + this._flatCommands(this._items).length;
417
+ if (leafCount <= 1 && !this._items.some(i => i.children)) {
418
+ nav.style.display = 'none';
419
+ return;
420
+ }
421
+ nav.style.display = '';
422
+ this._renderNavLevel(nav, this._items, 0);
423
+ // Roving tabindex: only the first focusable button is in tab order
424
+ const allBtns = nav.querySelectorAll('button');
425
+ allBtns.forEach((b, i) => b.setAttribute('tabindex', i === 0 ? '0' : '-1'));
426
+ }
427
+
428
+ _renderNavLevel(parent, items, depth) {
429
+ items.forEach(item => {
430
+ if (item.children) {
431
+ const wrap = document.createElement('div');
432
+ wrap.className = 'sol-menu-group';
433
+ const btn = document.createElement('button');
434
+ btn.type = 'button';
435
+ btn.className = 'sol-menu-group-btn';
436
+ btn.textContent = item.name;
437
+ btn.setAttribute('role', 'menuitem');
438
+ btn.setAttribute('aria-haspopup', 'menu');
439
+ btn.setAttribute('aria-expanded', 'false');
440
+ if (item.requiresWrite) btn.setAttribute('part', 'item requires-write');
441
+ const popup = document.createElement('div');
442
+ popup.className = 'sol-menu-popup';
443
+ popup.setAttribute('role', 'menu');
444
+ popup.setAttribute('aria-label', item.name);
445
+ this._renderNavLevel(popup, item.children, depth + 1);
446
+ btn.onclick = (e) => {
447
+ e.stopPropagation();
448
+ const wasOpen = wrap.classList.contains('open');
449
+ this._closeSiblingPopups(wrap);
450
+ wrap.classList.toggle('open', !wasOpen);
451
+ btn.setAttribute('aria-expanded', String(!wasOpen));
452
+ if (!wasOpen) {
453
+ this._positionPopup(btn, popup, depth);
454
+ const first = popup.querySelector('button');
455
+ if (first) { first.setAttribute('tabindex', '0'); first.focus(); }
456
+ }
457
+ };
458
+ wrap.appendChild(btn);
459
+ wrap.appendChild(popup);
460
+ parent.appendChild(wrap);
461
+ } else {
462
+ const btn = document.createElement('button');
463
+ btn.type = 'button';
464
+ btn.setAttribute('role', 'menuitem');
465
+ // Surface the declared access requirement for the app to act on; the
466
+ // menu itself takes no policy (no hide / disable here).
467
+ if (item.requiresWrite) btn.setAttribute('part', 'item requires-write');
468
+ if (item.icon) {
469
+ btn.title = item.name;
470
+ btn.setAttribute('aria-label', item.name);
471
+ const span = document.createElement('span');
472
+ span.className = 'sol-menu-icon';
473
+ span.setAttribute('aria-hidden', 'true');
474
+ btn.appendChild(span);
475
+ if (item.icon.startsWith('data:image/svg+xml')) {
476
+ try {
477
+ const raw = decodeURIComponent(item.icon.replace('data:image/svg+xml,', ''));
478
+ span.innerHTML = raw;
479
+ const svg = span.querySelector('svg');
480
+ if (svg) { svg.setAttribute('width', '1.2em'); svg.setAttribute('height', '1.2em'); }
481
+ } catch { span.textContent = item.name; }
482
+ } else {
483
+ const img = document.createElement('img');
484
+ img.src = item.icon;
485
+ img.alt = '';
486
+ span.appendChild(img);
487
+ }
488
+ } else {
489
+ btn.textContent = item.name;
490
+ }
491
+ if (item.command) {
492
+ btn.onclick = () => { dispatchCommand(this, item.command, item.params); this._closeAllPopups(); };
493
+ } else {
494
+ btn.onclick = () => { this.select(item.name); this._closeAllPopups(); };
495
+ }
496
+ parent.appendChild(btn);
497
+ this._btns[item.name] = btn;
498
+ }
499
+ });
500
+ }
501
+
502
+ _positionPopup(btn, popup, depth) {
503
+ const r = btn.getBoundingClientRect();
504
+ const horizontal = this.getAttribute('orientation') === 'horizontal';
505
+ const flyBelow = horizontal && depth === 0;
506
+ popup.style.top = (flyBelow ? r.bottom + 2 : r.top) + 'px';
507
+ popup.style.left = (flyBelow ? r.left : r.right + 2) + 'px';
508
+ }
509
+
510
+ _closeSiblingPopups(keep) {
511
+ const parent = keep.parentElement;
512
+ if (!parent) return;
513
+ parent.querySelectorAll(':scope > .sol-menu-group.open').forEach(g => {
514
+ if (g !== keep) {
515
+ g.classList.remove('open');
516
+ const b = g.querySelector(':scope > .sol-menu-group-btn');
517
+ if (b) b.setAttribute('aria-expanded', 'false');
518
+ }
519
+ });
520
+ }
521
+
522
+ _closeAllPopups() {
523
+ this.shadowRoot.querySelectorAll('.sol-menu-group.open').forEach(g => {
524
+ g.classList.remove('open');
525
+ const b = g.querySelector(':scope > .sol-menu-group-btn');
526
+ if (b) b.setAttribute('aria-expanded', 'false');
527
+ });
528
+ }
529
+
530
+ select(name) {
531
+ const item = this._flatLeaves(this._items).find(t => t.name.toLowerCase() === name.toLowerCase());
532
+ if (!item) return;
533
+ this._active = item.name;
534
+
535
+ if (typeof this._cleanup === 'function') { this._cleanup(); this._cleanup = null; }
536
+ this._setActiveButton(item.name);
537
+
538
+ const body = this.body;
539
+ body.innerHTML = '';
540
+ body.style.padding = ''; body.style.overflow = ''; body.style.height = '';
541
+ body.setAttribute('aria-label', `Content: ${item.name}`);
542
+
543
+ const cleanup = item.render(body);
544
+ if (typeof cleanup === 'function') this._cleanup = cleanup;
545
+
546
+ this.dispatchEvent(new CustomEvent('sol-menu-change', {
547
+ bubbles: true, composed: true, detail: { name: item.name },
548
+ }));
549
+ }
550
+
551
+ /**
552
+ * Update the visual active state on the nav buttons. Passing a name
553
+ * that isn't one of this menu's leaves clears every button — the
554
+ * menu owns no active item (e.g. a sol-button mounted something
555
+ * other than a menu target into the linkTarget).
556
+ */
557
+ _setActiveButton(name) {
558
+ Object.values(this._btns).forEach(b => {
559
+ b.classList.remove('active');
560
+ b.removeAttribute('aria-current');
561
+ b.setAttribute('tabindex', '-1');
562
+ });
563
+ if (!name) return;
564
+ const btn = this._btns[name];
565
+ if (!btn) return;
566
+ btn.classList.add('active');
567
+ btn.setAttribute('aria-current', 'page');
568
+ btn.setAttribute('tabindex', '0');
569
+ }
570
+
571
+ /**
572
+ * Re-read `from-rdf` and rebuild the menu nav. Public hook used by
573
+ * external editors (e.g. dk-settings) after the menu TTL changes.
574
+ * A menu built from declared light-DOM anchors has no source to
575
+ * re-read; reload is a no-op in that case.
576
+ */
577
+ async reload() {
578
+ const uri = this.getAttribute('from-rdf');
579
+ if (uri) await this._loadFromRdf(uri);
580
+ }
581
+
582
+ disconnectedCallback() {
583
+ if (typeof this._cleanup === 'function') { this._cleanup(); this._cleanup = null; }
584
+ if (this._onDocClick) { document.removeEventListener('click', this._onDocClick); this._onDocClick = null; }
585
+ if (this._onKeyDown) { this.shadowRoot.removeEventListener('keydown', this._onKeyDown); this._onKeyDown = null; }
586
+ if (this._onTabActivate) { document.removeEventListener('sol-tab-activate', this._onTabActivate); this._onTabActivate = null; }
587
+ }
588
+ }
589
+
590
+ define('sol-menu', SolMenu);
591
+ registerMenuConsumer(SolMenu);
592
+ export { SolMenu };
593
+ export default SolMenu;