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,445 @@
1
+ /**
2
+ * <sol-tabs> — Tabbed content container.
3
+ *
4
+ * Light-DOM element so the hosting context's styles (e.g. the modal's
5
+ * shadow-scoped `.modal-*` classes) reach the tab content.
6
+ *
7
+ * Imperative usage:
8
+ * const t = document.createElement('sol-tabs');
9
+ * t.tabs = [
10
+ * { name: 'View', render(body, footer, actions) { ... } },
11
+ * { name: 'Edit', render(body, footer, actions) { ... } },
12
+ * ];
13
+ * t.footerEl = someFooterEl;
14
+ * t.actionsEl = someActionsEl;
15
+ * parent.appendChild(t);
16
+ * t.switchTab('View');
17
+ *
18
+ * Declarative usage: fill the element with <a href="...">Label</a> anchors.
19
+ * Each anchor becomes a tab — label = text, content URL = href. Contents
20
+ * render lazily on first switch. Set `handler="sol-*"` on the anchor (or
21
+ * on <sol-tabs> as a default) to wrap the URL in that component; otherwise
22
+ * <sol-include> is used. The href is forwarded as both `source` and
23
+ * `endpoint`, and all other anchor attributes pass through — so e.g.
24
+ * `wanted="? ? ?"` on an anchor with `handler="sol-query"` just works.
25
+ *
26
+ * `handler` and the forwarded attributes may be written `data-*` to keep a
27
+ * standard <a> HTML-valid; the `data-` prefix is stripped when forwarding
28
+ * (`data-handler` picks the tag, `data-src` → `src`, `data-view` → `view`, …).
29
+ *
30
+ * <sol-tabs>
31
+ * <a href="notes.md">Notes</a>
32
+ * <a href="data.ttl" handler="sol-query" wanted="? ? ?">Table</a>
33
+ * <a href="lib.ttl" data-handler="ia-player" data-src="lib.ttl">Music</a>
34
+ * </sol-tabs>
35
+ *
36
+ * <sol-tabs handler="sol-live-edit">
37
+ * <a href="readme.md">Readme</a>
38
+ * </sol-tabs>
39
+ *
40
+ * Action launchers: tabs are the `<a href>` children; ANY OTHER element child
41
+ * (a button, a custom control) is treated as a toolbar action — re-homed into
42
+ * the tab bar's actions row (next to the tabs) and otherwise left as-is, so
43
+ * toolbar controls live in the same markup with no marker. `slot="actions"` is
44
+ * an explicit escape hatch (force an <a> to be an action, or be explicit). An
45
+ * inline <sol-button> action is auto-wired to this tabs' content area (no `for=`):
46
+ *
47
+ * <sol-tabs>
48
+ * <a href="a.html">A</a>
49
+ * <sol-button inline handler="sol-include" source="help.html">?</sol-button>
50
+ * </sol-tabs>
51
+ *
52
+ * RDF usage (opt-in): point `from-rdf` at a ui:Menu document — the same RDF
53
+ * shape <sol-menu> consumes. Each ui:Link / ui:Component part becomes a tab; a
54
+ * nested ui:Menu becomes a tab whose content is a slimmer
55
+ * <sol-tabs variant="sub"> strip of that group's children. `from-rdf` is inert
56
+ * until the `web/menu-from-rdf.js` add-on is imported (the lone rdflib pull —
57
+ * it keeps the declarative path dependency-free); without it this element stays
58
+ * declarative-only and waits for the add-on if one arrives later.
59
+ *
60
+ * import 'sol-components/menu-from-rdf.js'; // activation
61
+ * <sol-tabs from-rdf="./demo-tabs.ttl#MainTabs"></sol-tabs>
62
+ *
63
+ * The tab bar is hidden when only one tab is supplied. Set attribute
64
+ * `variant="sub"` for the slimmer nested subtab styling.
65
+ *
66
+ * Events (bubbling, composed):
67
+ * sol-tab-change — detail: { name }
68
+ * sol-error — detail: { source, kind, ... } on RDF / handler load failure
69
+ */
70
+
71
+ import { define } from '../core/define.js';
72
+ import { ensureDocStyle } from '../core/adopt.js';
73
+ import { CSS as TABS_CSS } from './styles/sol-tabs-css.js';
74
+ import { attachEditorSelfGear } from '../core/editor-self.js';
75
+ import { registerMenuConsumer, deferUntilLoader } from '../core/menu-consumer.js';
76
+ import { renderComponentItem, renderLinkItem, ensureHandler, isCommandName } from '../core/rdf-render.js';
77
+
78
+ // For auto-wiring an inline action launcher to this tabs' content area we need
79
+ // a stable selector; mint an id for any <sol-tabs> that lacks one.
80
+ let _solTabsUid = 0;
81
+
82
+ /**
83
+ * Tabbed content container.
84
+ *
85
+ * Light-DOM element. Fill with anchor children (declarative) or set
86
+ * the `.tabs` property (imperative). Tab bar is hidden for a single tab.
87
+ *
88
+ * @class SolTabs
89
+ * @extends HTMLElement
90
+ * @attr {string} orientation - "horizontal" (default) or "vertical"
91
+ * @attr {string} handler - default sol-* component tag for all tabs
92
+ * @attr {string} variant - "sub" for slimmer nested subtab styling
93
+ * @attr {string} from-rdf - URL of a ui:Menu RDF document to build tabs from
94
+ * @fires sol-tab-change - detail: { name }
95
+ * @fires sol-error - detail: { source, kind } on RDF / handler load failure
96
+ */
97
+ class SolTabs extends HTMLElement {
98
+ constructor() {
99
+ super();
100
+ this._tabs = [];
101
+ this._btns = {};
102
+ this._active = null;
103
+ this._cleanup = null;
104
+ this._footerEl = null;
105
+ this._actionsEl = null;
106
+ this._launchers = null;
107
+ this._rendered = false;
108
+ }
109
+
110
+ static get observedAttributes() { return ['from-rdf']; }
111
+
112
+ // `from-rdf` rendering is an opt-in capability: importing `web/menu-from-rdf.js`
113
+ // installs the rdflib-backed loader here. Null → this component is declarative-
114
+ // only and carries no rdflib (see core/menu-consumer.js).
115
+ static fromRdfLoader = null;
116
+
117
+ // Keep-alive: render every tab once into its own persistent pane and
118
+ // switch by toggling visibility, so components are never torn down —
119
+ // audio keeps playing, scroll / login / in-flight state survive.
120
+ get _keepAlive() { return this.hasAttribute('keep-alive'); }
121
+
122
+ /**
123
+ * Form TTL describing how to edit this tabs' `from-rdf` subject.
124
+ * sol-tabs and sol-menu share the same `ui:Menu` shape, so they
125
+ * also share the same editor.
126
+ */
127
+ static get editor() {
128
+ return new URL('../data/menu-form.ttl', import.meta.url).href;
129
+ }
130
+
131
+ attributeChangedCallback(name, oldValue, newValue) {
132
+ if (name === 'from-rdf' && oldValue !== newValue && this._rendered) {
133
+ this._loadFromRdf(newValue);
134
+ }
135
+ }
136
+
137
+ connectedCallback() {
138
+ ensureDocStyle(this.getRootNode(), 'sol-tabs-styles', TABS_CSS);
139
+ if (this._rendered) return;
140
+
141
+ const fromRdf = this.getAttribute('from-rdf');
142
+
143
+ // Harvest declarative anchors before we overwrite innerHTML.
144
+ const declared = (!fromRdf && this._tabs.length === 0)
145
+ ? this._harvestAnchors() : null;
146
+
147
+ // Declarative PAGE-LEVEL action launchers (e.g. a <sol-button> toolbar
148
+ // control). A child is an action — not a tab — when it's NOT an `<a href>`
149
+ // tab anchor; `slot="actions"` stays as an explicit escape hatch (e.g. to
150
+ // mark an <a> as an action, or force the classification). They're detached
151
+ // so they survive the innerHTML reset; _renderBar re-homes them onto the bar
152
+ // (right side). Unlike the per-tab `.sol-tabs-actions` row — which switchTab
153
+ // clears on every switch — these persist across tabs. An inline <sol-button>
154
+ // is auto-wired to this tabs' content area (no `for=` needed).
155
+ this._launchers = Array.from(this.children).filter(
156
+ (el) => el.matches('[slot="actions"]') || !el.matches('a[href]'));
157
+ for (const el of this._launchers) { el.remove(); this._wireInlineAction(el); }
158
+
159
+ this.innerHTML = `
160
+ <div class="sol-tabs-bar" role="tablist"></div>
161
+ <div class="sol-tabs-actions"></div>
162
+ <div class="sol-tabs-content"></div>`;
163
+ this._rendered = true;
164
+
165
+ // Default actions slot sits between the bar and the content. Tabs
166
+ // that want toolbar buttons (save / zoom / settings / help, etc.)
167
+ // can append into actionsEl. Callers may still override via
168
+ // `tabsEl.actionsEl = someExternalEl` before switchTab.
169
+ if (!this._actionsEl) {
170
+ this._actionsEl = this.querySelector(':scope > .sol-tabs-actions');
171
+ }
172
+
173
+ if (fromRdf) {
174
+ this._loadFromRdf(fromRdf);
175
+ } else {
176
+ if (declared?.length) {
177
+ this._tabs = declared;
178
+ }
179
+ this._renderBar();
180
+
181
+ if (declared?.length) this._activateInitial();
182
+ }
183
+
184
+ if (this.hasAttribute('editor-self')) attachEditorSelfGear(this);
185
+ }
186
+
187
+ // Fetch a ui:Menu RDF document and render its parts as tabs. This is the
188
+ // exact shape <sol-menu> consumes — ui:parts of ui:Link / ui:Component
189
+ // with ui:label / ui:href / ui:contents / ui:name — so a single RDF
190
+ // document can drive either element. A nested ui:Menu becomes a tab whose
191
+ // body holds a slimmer <sol-tabs variant="sub"> strip of its children.
192
+ async _loadFromRdf(uri) {
193
+ const load = this.constructor.fromRdfLoader;
194
+ if (!load) { deferUntilLoader(this); return; } // wait for the menu-from-rdf add-on
195
+ try {
196
+ const result = await load(uri, document.baseURI);
197
+ if (!result) return;
198
+ if (result.orientation && !this.hasAttribute('orientation')) {
199
+ this.setAttribute('orientation', result.orientation);
200
+ }
201
+ // A part marked slot="actions" is a toolbar launcher, not a tab — build it
202
+ // as an element on the bar's action row (mirrors the inline non-anchor
203
+ // launchers). In RDF mode these REPLACE any inline launchers (the
204
+ // completeness principle: everything comes from RDF).
205
+ const isAction = (d) => d.type === 'component'
206
+ && (d.params || []).some(([k, v]) => k === 'slot' && v === 'actions');
207
+ const items = result.items || [];
208
+ const actionItems = items.filter(isAction);
209
+ this._tabs = this._wrapRdfItems(items.filter((d) => !isAction(d)));
210
+ if (actionItems.length) {
211
+ this._launchers = actionItems.map((d) => this._buildLauncher(d));
212
+ for (const el of this._launchers) this._wireInlineAction(el);
213
+ }
214
+ this._renderBar();
215
+ if (this._tabs.length) this._activateInitial();
216
+ } catch (err) {
217
+ console.error('<sol-tabs> from-rdf load failed:', err);
218
+ this.dispatchEvent(new CustomEvent('sol-error', {
219
+ bubbles: true, composed: true,
220
+ detail: { source: 'sol-tabs', kind: 'rdf-load', uri, message: err.message },
221
+ }));
222
+ }
223
+ }
224
+
225
+ // Build a toolbar launcher element from an RDF action descriptor (ui:name =
226
+ // tag, ui:label → text, ui:attribute → attributes; the slot="actions" marker
227
+ // is dropped). Mirrors an inline non-anchor launcher.
228
+ _buildLauncher(desc) {
229
+ const el = document.createElement(desc.tag);
230
+ for (const [k, v] of desc.params || []) {
231
+ if (k === 'slot' && v === 'actions') continue;
232
+ el.setAttribute(k, v);
233
+ }
234
+ if (desc.name) el.textContent = desc.name; // ?, A, 🌙 — empty for login/dropdown
235
+ return el;
236
+ }
237
+
238
+ // Wrap the plain item descriptions from core/menu-rdf.js with render
239
+ // closures. Leaf links/components use the shared factory in
240
+ // core/rdf-render.js; a nested ui:Menu becomes a tab whose body is a
241
+ // <sol-tabs variant="sub"> holding the group's own children.
242
+ _wrapRdfItems(descriptions) {
243
+ const ctx = {
244
+ host: this, baseUrl: import.meta.url,
245
+ sourceName: 'sol-tabs', embedClass: 'sol-tab-embed',
246
+ };
247
+ return descriptions.map(desc => {
248
+ if (desc.type === 'submenu') {
249
+ const children = this._wrapRdfItems(desc.children);
250
+ return {
251
+ name: desc.name,
252
+ id: desc.id,
253
+ render: (body) => {
254
+ const sub = document.createElement('sol-tabs');
255
+ sub.setAttribute('variant', 'sub');
256
+ sub.tabs = children;
257
+ body.appendChild(sub);
258
+ if (children.length) sub.switchTab(children[0].name);
259
+ },
260
+ };
261
+ }
262
+ if (desc.type === 'component') {
263
+ // Command items (ui:name is a registry key, not a tag) are a menu
264
+ // affordance, not content — a tab can't "run" something. Skip them.
265
+ if (isCommandName(desc.tag)) return null;
266
+ return { name: desc.name, id: desc.id, render: renderComponentItem(desc, ctx) };
267
+ }
268
+ return { name: desc.name, id: desc.id, render: renderLinkItem(desc, ctx) };
269
+ }).filter(Boolean);
270
+ }
271
+
272
+ // Parse <a href="url" [handler="tag"] [attr=val ...]>Label</a> children
273
+ // into tab descriptors. Each tab's render() creates the component named
274
+ // by the anchor's `handler` attribute (falling back to the sol-tabs-level
275
+ // `handler` attribute, finally to <sol-include>). The href is passed to
276
+ // the created element as both `source` and `endpoint` so components that
277
+ // use either convention (sol-include / sol-live-edit use source, sol-query
278
+ // uses endpoint) pick it up. All other anchor attributes are forwarded.
279
+ // Auto-wire an inline action launcher (<sol-button inline>) to this tabs'
280
+ // content area, so the author needn't repeat a `for=` selector. No-op when it
281
+ // already has `for=` or isn't an inline sol-button.
282
+ _wireInlineAction(el) {
283
+ if (!el.tagName || el.tagName.toLowerCase() !== 'sol-button') return;
284
+ if (!el.hasAttribute('inline') || el.hasAttribute('for')) return;
285
+ if (!this.id) this.id = `sol-tabs-${++_solTabsUid}`;
286
+ el.setAttribute('for', `#${this.id} > .sol-tabs-content`);
287
+ }
288
+
289
+ _harvestAnchors() {
290
+ // Anchors marked slot="actions" are launchers, not tabs — skip them here.
291
+ const anchors = Array.from(this.querySelectorAll(':scope > a[href]:not([slot="actions"])'));
292
+ if (!anchors.length) return [];
293
+ // `handler` may be written plain or as `data-handler` (the latter keeps a
294
+ // standard <a> HTML-valid). Same for the forwarded attributes below.
295
+ const parentHandler = (this.getAttribute('data-handler') || this.getAttribute('handler') || '').trim();
296
+ const SKIP = new Set(['href', 'handler', 'data-handler', 'data-tab-id', 'target', 'rel', 'download', 'hreflang', 'type', 'referrerpolicy']);
297
+ return anchors.map((a, i) => {
298
+ const label = (a.textContent || '').trim() || `Tab ${i + 1}`;
299
+ const url = a.getAttribute('href');
300
+ const handlerTag = (a.getAttribute('data-handler') || a.getAttribute('handler') || parentHandler || 'sol-include').trim();
301
+ return {
302
+ name: label,
303
+ // The tab id (→ button data-tab-id, for styling/selection) can be set
304
+ // explicitly with data-tab-id, independent of the anchor's id — the
305
+ // latter is forwarded to become the content element's id.
306
+ id: a.dataset.tabId || a.id || undefined,
307
+ render: (body) => {
308
+ ensureHandler(handlerTag, this, import.meta.url, 'sol-tabs');
309
+ const el = document.createElement(handlerTag);
310
+ el.setAttribute('source', url);
311
+ el.setAttribute('endpoint', url);
312
+ for (const attr of a.attributes) {
313
+ if (SKIP.has(attr.name)) continue;
314
+ // `data-*` author attributes forward with the prefix stripped, so a
315
+ // standard <a> stays HTML-valid: data-src → src, data-view → view.
316
+ const name = attr.name.startsWith('data-') ? attr.name.slice(5) : attr.name;
317
+ el.setAttribute(name, attr.value);
318
+ }
319
+ el.classList.add('sol-tab-embed');
320
+ body.appendChild(el);
321
+ },
322
+ };
323
+ });
324
+ }
325
+
326
+ get tabs() { return this._tabs; }
327
+ set tabs(arr) {
328
+ this._tabs = arr || [];
329
+ if (this._rendered) this._renderBar();
330
+ }
331
+
332
+ get footerEl() { return this._footerEl; }
333
+ set footerEl(el) { this._footerEl = el; }
334
+
335
+ get actionsEl() { return this._actionsEl; }
336
+ set actionsEl(el) { this._actionsEl = el; }
337
+
338
+ get activeTab() { return this._active; }
339
+ get body() { return this.querySelector(':scope > .sol-tabs-content'); }
340
+
341
+ _renderBar() {
342
+ const bar = this.querySelector(':scope > .sol-tabs-bar');
343
+ if (!bar) return;
344
+ bar.innerHTML = '';
345
+ this._btns = {};
346
+ const launchers = this._launchers || [];
347
+ // Hide the bar only when there's nothing to show — a lone tab AND no
348
+ // page-level launchers. Launchers alone keep the bar visible.
349
+ if (this._tabs.length <= 1 && !launchers.length) { bar.style.display = 'none'; return; }
350
+ bar.style.display = '';
351
+ this._tabs.forEach(tab => {
352
+ const btn = document.createElement('button');
353
+ btn.type = 'button';
354
+ btn.setAttribute('role', 'tab');
355
+ btn.textContent = tab.name;
356
+ if (tab.id) btn.dataset.tabId = tab.id;
357
+ btn.onclick = () => this.switchTab(tab.name);
358
+ bar.appendChild(btn);
359
+ this._btns[tab.name] = btn;
360
+ });
361
+ // Page-level action launchers, grouped on the right of the bar. Re-appended
362
+ // on every bar render (so they survive a tabs reload); persist across switches.
363
+ if (launchers.length) {
364
+ const group = document.createElement('span');
365
+ group.className = 'sol-tabs-launch';
366
+ for (const el of launchers) group.appendChild(el);
367
+ bar.appendChild(group);
368
+ }
369
+ }
370
+
371
+ // Render every tab once (keep-alive) then show the first, else just
372
+ // show the first (lazy default path).
373
+ _activateInitial() {
374
+ if (!this._tabs.length) return;
375
+ if (this._keepAlive) {
376
+ this.body.innerHTML = ''; // drop any panes from a prior load (reload)
377
+ for (const t of this._tabs) this._ensurePane(t);
378
+ }
379
+ this.switchTab(this._tabs[0].name);
380
+ }
381
+
382
+ // Build (once) a persistent pane for a tab and render its content into it.
383
+ _ensurePane(tab) {
384
+ if (tab._pane) return tab._pane;
385
+ const pane = document.createElement('div');
386
+ pane.className = 'sol-tabs-pane';
387
+ if (tab.id) pane.dataset.tabId = tab.id;
388
+ pane.dataset.tabName = tab.name;
389
+ pane.hidden = true;
390
+ this.body.appendChild(pane);
391
+ tab._pane = pane;
392
+ tab.render(pane, this._footerEl, this._actionsEl);
393
+ return pane;
394
+ }
395
+
396
+ switchTab(name) {
397
+ const tab = this._tabs.find(t => t.name.toLowerCase() === name.toLowerCase());
398
+ if (!tab) return;
399
+ this._active = tab.name;
400
+
401
+ Object.values(this._btns).forEach(b => b.classList.remove('active'));
402
+ if (this._btns[tab.name]) this._btns[tab.name].classList.add('active');
403
+
404
+ if (this._keepAlive) {
405
+ // No teardown: ensure this tab's pane exists, then park the others.
406
+ this._ensurePane(tab);
407
+ for (const t of this._tabs) if (t._pane) t._pane.hidden = (t !== tab);
408
+ } else {
409
+ if (typeof this._cleanup === 'function') { this._cleanup(); this._cleanup = null; }
410
+
411
+ const body = this.body;
412
+ body.innerHTML = '';
413
+ body.style.padding = ''; body.style.overflow = ''; body.style.height = '';
414
+ if (this._footerEl) this._footerEl.innerHTML = '';
415
+ if (this._actionsEl) this._actionsEl.innerHTML = '';
416
+
417
+ const cleanup = tab.render(body, this._footerEl, this._actionsEl);
418
+ if (typeof cleanup === 'function') this._cleanup = cleanup;
419
+ }
420
+
421
+ this.dispatchEvent(new CustomEvent('sol-tab-change', {
422
+ bubbles: true, composed: true, detail: { name: tab.name },
423
+ }));
424
+ }
425
+
426
+ /**
427
+ * Re-read `from-rdf` and rebuild the tab bar. Public hook used by
428
+ * external editors (e.g. dk-settings) after the tabs TTL changes.
429
+ * Tabs declared via light-DOM anchors have no source to re-read;
430
+ * reload is a no-op in that case.
431
+ */
432
+ async reload() {
433
+ const uri = this.getAttribute('from-rdf');
434
+ if (uri) await this._loadFromRdf(uri);
435
+ }
436
+
437
+ disconnectedCallback() {
438
+ if (typeof this._cleanup === 'function') { this._cleanup(); this._cleanup = null; }
439
+ }
440
+ }
441
+
442
+ define('sol-tabs', SolTabs);
443
+ registerMenuConsumer(SolTabs);
444
+ export { SolTabs };
445
+ export default SolTabs;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * <sol-time> — clock display web component.
3
+ *
4
+ * Always shows local and UTC. An optional third timezone (label + hour
5
+ * offset from UTC) appears when either attribute is set. Updates once
6
+ * a minute.
7
+ *
8
+ * Attributes:
9
+ * time-label — short label for an extra timezone (e.g. "tokyo")
10
+ * time-offset — that timezone's offset from UTC in hours (e.g. "9")
11
+ * source — "file.ttl#Subject" Turtle config in schema.org
12
+ * PropertyValue form. Setting names map to the
13
+ * matching HTML attributes:
14
+ * "timezone" → time-label
15
+ * "timezone-offset" → time-offset
16
+ * HTML attributes override the TTL.
17
+ *
18
+ * @element sol-time
19
+ *
20
+ * @example
21
+ * <sol-time></sol-time>
22
+ * <sol-time time-label="tokyo" time-offset="9"></sol-time>
23
+ * <sol-time source="data/time-settings.ttl#Settings"></sol-time>
24
+ */
25
+ import { adopt } from '../core/adopt.js';
26
+ import { define } from '../core/define.js';
27
+ import { attachEditorSelfGear } from '../core/editor-self.js';
28
+ import { CSS as TIME_CSS, sheet as TIME_SHEET } from './styles/sol-time-css.js';
29
+ import { loadConfig } from './utils/rdf-config.js';
30
+
31
+ /**
32
+ * Derive the current UTC offset (in hours, possibly fractional) for an
33
+ * IANA timezone name. Returns null if the name is unrecognised.
34
+ *
35
+ * Uses Intl.DateTimeFormat's "shortOffset" timezone name — emitted as
36
+ * strings like `"GMT+5:30"`, `"GMT-04:00"`, or just `"GMT"`. Parses
37
+ * the suffix back to a decimal hours value. Honours DST automatically
38
+ * because the formatter consults the OS / browser's IANA database.
39
+ */
40
+ function ianaOffsetHours(iana) {
41
+ try {
42
+ const fmt = new Intl.DateTimeFormat('en-US', {
43
+ timeZone: iana,
44
+ timeZoneName: 'shortOffset',
45
+ });
46
+ const parts = fmt.formatToParts(new Date());
47
+ const tz = parts.find(p => p.type === 'timeZoneName')?.value;
48
+ if (!tz) return null;
49
+ // tz looks like "GMT", "GMT+5:30", "GMT-04:00", "UTC+11", …
50
+ if (tz === 'GMT' || tz === 'UTC') return 0;
51
+ const m = tz.match(/^(?:GMT|UTC)([+-])(\d{1,2})(?::(\d{2}))?$/);
52
+ if (!m) return null;
53
+ const sign = m[1] === '-' ? -1 : 1;
54
+ const hours = parseInt(m[2], 10) + (parseInt(m[3] || '0', 10) / 60);
55
+ return sign * hours;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** Zero-pad a one- or two-digit clock value to two chars. */
62
+ function pad2(n) { return n < 10 ? '0' + n : String(n); }
63
+
64
+ /**
65
+ * Clock display web component.
66
+ *
67
+ * @class SolTime
68
+ * @extends HTMLElement
69
+ */
70
+ class SolTime extends HTMLElement {
71
+ static get observedAttributes() {
72
+ return ['time-label', 'time-offset', 'source'];
73
+ }
74
+
75
+ /** SHACL shape declaring the fixed schema (predicates + datatypes +
76
+ * cardinalities). sol-form's shape-driven mode generates a labelled
77
+ * field per property; dk-settings discovery picks this up. The
78
+ * legacy `editor` (ui:Form TTL) getter was dropped in the
79
+ * direct-predicate vocab migration — see
80
+ * swc/claude/plans/PLAN-vocab-migration.md. */
81
+ static get shape() {
82
+ return new URL('../shapes/time-settings.shacl', import.meta.url).href;
83
+ }
84
+
85
+ constructor() {
86
+ super();
87
+ this.attachShadow({ mode: 'open' });
88
+ this._root = document.createElement('div');
89
+ this._root.className = 'sol-time';
90
+ this._timer = null;
91
+ }
92
+
93
+ async connectedCallback() {
94
+ adopt(this.shadowRoot, { sheet: TIME_SHEET, css: TIME_CSS });
95
+ this.shadowRoot.appendChild(this._root);
96
+
97
+ // Pull defaults from the configured RDF source; explicit HTML
98
+ // attributes win, so the TTL is a baseline rather than a forced
99
+ // setting. Render once synchronously so the clock isn't blank
100
+ // during the async fetch.
101
+ this._render();
102
+ await this._applySource();
103
+ this._render();
104
+ // Tick once a minute — the seconds are not shown so a finer tick
105
+ // would be pure busywork.
106
+ this._timer = setInterval(() => this._render(), 60_000);
107
+
108
+ if (this.hasAttribute('editor-self')) attachEditorSelfGear(this);
109
+ }
110
+
111
+ /**
112
+ * Apply config from `source` to attributes the component already
113
+ * observes. Mapping (predicate URI → HTML attribute):
114
+ * schema:timezone → time-label (display label = last IANA segment)
115
+ * → time-offset (UTC offset in hours, derived)
116
+ *
117
+ * The IANA name in schema:timezone fully determines both the label
118
+ * (`"Asia/Kolkata"` → `"Kolkata"`) and the UTC offset (computed via
119
+ * `Intl.DateTimeFormat`, which honors DST). See
120
+ * claude/plans/PLAN-vocab-migration.md for the predicate choice and
121
+ * rationale.
122
+ */
123
+ async _applySource() {
124
+ const source = this.getAttribute('source');
125
+ if (!source) return;
126
+ const SCHEMA = 'http://schema.org/';
127
+ try {
128
+ const cfg = await loadConfig(source);
129
+ const iana = cfg[SCHEMA + 'timezone'];
130
+ if (!iana) return;
131
+ if (!this.hasAttribute('time-label')) {
132
+ // Display label = last path segment (the city/place part).
133
+ const label = String(iana).split('/').pop() || String(iana);
134
+ this.setAttribute('time-label', label);
135
+ }
136
+ if (!this.hasAttribute('time-offset')) {
137
+ const offset = ianaOffsetHours(String(iana));
138
+ if (offset != null) this.setAttribute('time-offset', String(offset));
139
+ }
140
+ } catch (err) {
141
+ console.warn(`[sol-time] source ${source}: ${err.message}`);
142
+ }
143
+ }
144
+
145
+ disconnectedCallback() {
146
+ if (this._timer) { clearInterval(this._timer); this._timer = null; }
147
+ }
148
+
149
+ /**
150
+ * Re-read `source` and re-render. Public hook used by external
151
+ * editors (e.g. dk-settings) after a configuration file changes.
152
+ */
153
+ async reload() {
154
+ await this._applySource();
155
+ this._render();
156
+ }
157
+
158
+ attributeChangedCallback() {
159
+ if (this.isConnected) this._render();
160
+ }
161
+
162
+ _render() {
163
+ const now = new Date();
164
+ const local = pad2(now.getHours()) + ':' + pad2(now.getMinutes());
165
+ const utc = pad2(now.getUTCHours()) + ':' + pad2(now.getUTCMinutes());
166
+
167
+ // local + gmt are always shown — the three label-value pairs read
168
+ // uniformly (label + value triplets).
169
+ const parts = [
170
+ '<span class="label" part="local-label">local</span>',
171
+ `<span class="value" part="local-time">${local}</span>`,
172
+ '<span class="sep" part="sep">·</span>',
173
+ '<span class="label" part="utc-label">gmt</span>',
174
+ `<span class="value" part="utc-time">${utc}</span>`,
175
+ ];
176
+
177
+ const label = this.getAttribute('time-label');
178
+ const offset = Number(this.getAttribute('time-offset'));
179
+ if (label && Number.isFinite(offset)) {
180
+ const extra = new Date(now.getTime() + offset * 3_600_000);
181
+ const t = pad2(extra.getUTCHours()) + ':' + pad2(extra.getUTCMinutes());
182
+ parts.push(
183
+ '<span class="sep" part="sep">·</span>',
184
+ `<span class="label" part="extra-label">${label}</span>`,
185
+ `<span class="value" part="extra-time">${t}</span>`,
186
+ );
187
+ }
188
+
189
+ this._root.innerHTML = parts.join('');
190
+ }
191
+ }
192
+
193
+ define('sol-time', SolTime);
194
+ export { SolTime };