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.
- package/README.md +7 -0
- package/core/activate.js +27 -0
- package/core/adopt.js +71 -0
- package/core/auth-core.js +73 -0
- package/core/auth-fetch.js +154 -0
- package/core/component-mount.js +110 -0
- package/core/defaults.js +48 -0
- package/core/define.js +15 -0
- package/core/display-target.js +166 -0
- package/core/edit-placements.js +28 -0
- package/core/editor-self.js +127 -0
- package/core/editor.js +162 -0
- package/core/events.js +27 -0
- package/core/extension-points.js +189 -0
- package/core/form-utils.js +210 -0
- package/core/from-query.js +138 -0
- package/core/from-rdf.js +52 -0
- package/core/here.js +33 -0
- package/core/include-core.js +73 -0
- package/core/inrupt-global.js +18 -0
- package/core/menu-consumer.js +41 -0
- package/core/menu-rdf.js +154 -0
- package/core/pod-ops.js +392 -0
- package/core/pod-registry.js +82 -0
- package/core/popup-proxy.js +255 -0
- package/core/rdf-core.js +280 -0
- package/core/rdf-render.js +136 -0
- package/core/rdf-utils.js +411 -0
- package/core/rdf.js +154 -0
- package/core/services.js +106 -0
- package/core/shape-to-form.js +741 -0
- package/core/sparql-safety.js +20 -0
- package/core/utils.js +196 -0
- package/dist/importmap-cdn.json +49 -0
- package/dist/importmap-local.json +49 -0
- package/dist/sol-loader.manifest.json +140 -0
- package/dist/vendor/@comunica-query-sparql.js +137851 -0
- package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
- package/dist/vendor/dompurify.js +1476 -0
- package/dist/vendor/ical.js.js +9739 -0
- package/dist/vendor/marked.js +85 -0
- package/dist/vendor/n3.js +14670 -0
- package/dist/vendor/rdf-validate-shacl.js +6970 -0
- package/dist/vendor/rdflib.js +35172 -0
- package/dist/vendor/solid-logic.js +6819 -0
- package/dist/vendor/solid-ui.js +21945 -0
- package/node/sol-form.js +133 -0
- package/node/sol-include.js +55 -0
- package/node/sol-login.js +632 -0
- package/node/sol-menu.js +639 -0
- package/node/sol-query.js +116 -0
- package/package.json +133 -0
- package/web/menu-from-rdf.js +23 -0
- package/web/scripts/prefs.js +25 -0
- package/web/sol-accordion.js +114 -0
- package/web/sol-basic.js +50 -0
- package/web/sol-breadcrumb.js +131 -0
- package/web/sol-button.js +244 -0
- package/web/sol-calendar.js +465 -0
- package/web/sol-default.js +118 -0
- package/web/sol-dropdown-button.js +222 -0
- package/web/sol-feed.js +1336 -0
- package/web/sol-form.js +949 -0
- package/web/sol-full.js +43 -0
- package/web/sol-gallery.js +303 -0
- package/web/sol-include.js +246 -0
- package/web/sol-live-edit.js +415 -0
- package/web/sol-login.js +856 -0
- package/web/sol-menu.js +593 -0
- package/web/sol-modal.js +377 -0
- package/web/sol-pod-extras.js +17 -0
- package/web/sol-pod-ops.js +680 -0
- package/web/sol-pod.js +1039 -0
- package/web/sol-query.js +546 -0
- package/web/sol-rolodex.js +95 -0
- package/web/sol-search.js +402 -0
- package/web/sol-settings.js +199 -0
- package/web/sol-solidos.js +93 -0
- package/web/sol-tabs.js +445 -0
- package/web/sol-time.js +194 -0
- package/web/sol-tree-edit.js +492 -0
- package/web/sol-wac.js +456 -0
- package/web/sol-weather.js +337 -0
- package/web/sol-window.js +142 -0
- package/web/styles/buttons-css.js +108 -0
- package/web/styles/help.css +242 -0
- package/web/styles/root.css +112 -0
- package/web/styles/sol-accordion-css.js +97 -0
- package/web/styles/sol-calendar-css.js +154 -0
- package/web/styles/sol-feed-css.js +475 -0
- package/web/styles/sol-form-css.js +471 -0
- package/web/styles/sol-gallery-css.js +181 -0
- package/web/styles/sol-include-css.js +95 -0
- package/web/styles/sol-live-edit-css.js +84 -0
- package/web/styles/sol-live-edit.css +101 -0
- package/web/styles/sol-login-css.js +116 -0
- package/web/styles/sol-menu-css.js +145 -0
- package/web/styles/sol-modal-css.js +134 -0
- package/web/styles/sol-pod-css.js +187 -0
- package/web/styles/sol-pod-modal-css.js +203 -0
- package/web/styles/sol-query-css.js +140 -0
- package/web/styles/sol-query-help.css +267 -0
- package/web/styles/sol-query-one-pager.css +67 -0
- package/web/styles/sol-search-css.js +157 -0
- package/web/styles/sol-solidos-css.js +7 -0
- package/web/styles/sol-tabs-css.js +114 -0
- package/web/styles/sol-time-css.js +30 -0
- package/web/styles/sol-wac-css.js +73 -0
- package/web/styles/sol-weather-css.js +59 -0
- package/web/styles/solid-logo.svg +9 -0
- package/web/styles/view-accordion-css.js +66 -0
- package/web/styles/view-anchorlist-css.js +22 -0
- package/web/styles/view-autocomplete-css.js +59 -0
- package/web/styles/view-rolodex-css.js +102 -0
- package/web/styles/view-select-css.js +21 -0
- package/web/utils/calendar-fetch.js +388 -0
- package/web/utils/code-mirror-editor.js +82 -0
- package/web/utils/commons-fetch.js +108 -0
- package/web/utils/feed-edit.js +159 -0
- package/web/utils/feed-edit.smoke.mjs +74 -0
- package/web/utils/feed-fetch.js +573 -0
- package/web/utils/live-edit-help/csv.js +64 -0
- package/web/utils/live-edit-help/graphviz.js +41 -0
- package/web/utils/live-edit-help/jsonld.js +55 -0
- package/web/utils/live-edit-help/markdown.js +52 -0
- package/web/utils/live-edit-help/mermaid.js +48 -0
- package/web/utils/live-edit-help/turtle.js +85 -0
- package/web/utils/rdf-config.js +125 -0
- package/web/utils/renderers/csv.js +124 -0
- package/web/utils/renderers/d3-force.js +82 -0
- package/web/utils/renderers/graphviz.js +13 -0
- package/web/utils/renderers/html.js +10 -0
- package/web/utils/renderers/jsonld.js +63 -0
- package/web/utils/renderers/markdown.js +19 -0
- package/web/utils/renderers/mermaid.js +54 -0
- package/web/utils/renderers/turtle.js +51 -0
- package/web/utils/sol-query-triple-patterns.js +151 -0
- package/web/utils/sol-query-ui.js +250 -0
- package/web/utils/sol-query-views.js +32 -0
- package/web/views/_helpers.js +34 -0
- package/web/views/accordion.js +133 -0
- package/web/views/anchorlist.js +59 -0
- package/web/views/auto-complete.js +183 -0
- package/web/views/dl.js +38 -0
- package/web/views/list.js +19 -0
- package/web/views/menu.js +56 -0
- package/web/views/rolodex.js +126 -0
- package/web/views/select.js +79 -0
- package/web/views/table.js +73 -0
- package/web/views/tabs.js +57 -0
package/web/sol-menu.js
ADDED
|
@@ -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;
|