@wpmoo/ui 1.0.0 → 1.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ /*!
2
+ * Moo UI table-of-contents.js v1.1.0-dev.1 (https://wpmoo.org/)
3
+ * Copyright 2026 WPMoo Authors
4
+ * Licensed under MIT (https://github.com/wpmoo-org/ui/blob/main/LICENSE)
5
+ */
6
+
7
+ const instances = /* @__PURE__ */ new WeakMap();
8
+ const ownerSelector = '.moo-ui[data-bs-theme="light"], .moo-ui[data-bs-theme="dark"]';
9
+ function activationOffset(options, previous = 0) {
10
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
11
+ throw new TypeError("TableOfContents options must be an object.");
12
+ }
13
+ const value = options.activationOffset === void 0 ? previous : options.activationOffset;
14
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
15
+ throw new TypeError("TableOfContents activationOffset must be a finite nonnegative number.");
16
+ }
17
+ return value;
18
+ }
19
+ function fragmentId(href) {
20
+ if (!href?.startsWith("#") || href.length === 1) return null;
21
+ try {
22
+ return decodeURIComponent(href.slice(1));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ class TableOfContents {
28
+ static getInstance(element) {
29
+ return element?.nodeType === 1 ? instances.get(element) || null : null;
30
+ }
31
+ static getOrCreateInstance(element, options = {}) {
32
+ return TableOfContents.getInstance(element) || new TableOfContents(element, options);
33
+ }
34
+ constructor(element, options = {}) {
35
+ if (element?.nodeType !== 1 || !element.matches("[data-toc]")) {
36
+ throw new TypeError("TableOfContents requires a [data-toc] root element.");
37
+ }
38
+ const existing = instances.get(element);
39
+ if (existing) return existing;
40
+ this._element = element;
41
+ this._document = element.ownerDocument;
42
+ this._window = this._document.defaultView;
43
+ this._offset = activationOffset(options);
44
+ this._listeners = [];
45
+ this._originalLinks = /* @__PURE__ */ new Map();
46
+ this._originalStatus = /* @__PURE__ */ new Map();
47
+ this._originalMarker = {
48
+ attribute: element.getAttribute("data-toc-marker"),
49
+ properties: ["--moo-toc-marker-offset", "--moo-toc-marker-height"].map((name) => ({
50
+ name,
51
+ value: element.style.getPropertyValue(name),
52
+ priority: element.style.getPropertyPriority(name)
53
+ }))
54
+ };
55
+ this._links = [];
56
+ this._targets = [];
57
+ this._frame = 0;
58
+ this._disposed = false;
59
+ instances.set(element, this);
60
+ this.refresh();
61
+ }
62
+ refresh(options = {}) {
63
+ if (this._disposed) return this;
64
+ const offset = activationOffset(options, this._offset);
65
+ this._clearListeners();
66
+ this._offset = offset;
67
+ const owner = this._element.closest(ownerSelector);
68
+ const contentId = this._element.getAttribute("data-toc-content");
69
+ const scope = contentId ? this._document.getElementById(contentId) : owner;
70
+ const rootId = this._element.getAttribute("data-toc-scroll-root");
71
+ this._scrollElement = rootId ? this._document.getElementById(rootId) : null;
72
+ this._status = Array.from(this._element.querySelectorAll("[data-toc-current]")).find((node) => node.closest("[data-toc]") === this._element) || null;
73
+ if (this._status && !this._originalStatus.has(this._status)) {
74
+ this._originalStatus.set(this._status, this._status.textContent);
75
+ }
76
+ this._overview = this._status ? this._originalStatus.get(this._status) : "";
77
+ this._links = Array.from(this._element.querySelectorAll('a[href^="#"]')).filter((link) => link.closest("[data-toc]") === this._element).map((link) => ({ link, id: fragmentId(link.getAttribute("href")) }));
78
+ this._links.forEach(({ link }) => {
79
+ if (!this._originalLinks.has(link)) {
80
+ this._originalLinks.set(link, {
81
+ active: link.classList.contains("active"),
82
+ current: link.getAttribute("aria-current")
83
+ });
84
+ }
85
+ });
86
+ this._targets = [];
87
+ this._activate(null);
88
+ if (!owner || !scope || scope.closest(ownerSelector) !== owner || rootId && (!this._scrollElement || !this._scrollElement.contains(scope))) {
89
+ return this;
90
+ }
91
+ const seen = /* @__PURE__ */ new Set();
92
+ this._links.forEach(({ id, link }) => {
93
+ const target = id ? this._document.getElementById(id) : null;
94
+ if (!target || seen.has(id) || !scope.contains(target) || target.closest(ownerSelector) !== owner) return;
95
+ seen.add(id);
96
+ this._targets.push({ id, target, label: link.textContent.trim() });
97
+ });
98
+ if (!this._targets.length) return this;
99
+ const queue = () => this._requestUpdate();
100
+ this._listen(this._scrollElement || this._window, "scroll", queue, { passive: true });
101
+ this._listen(this._window, "resize", queue);
102
+ this._listen(this._window, "hashchange", queue);
103
+ this._listen(this._element, "click", queue);
104
+ this._update();
105
+ return this;
106
+ }
107
+ dispose() {
108
+ if (this._disposed) return;
109
+ this._disposed = true;
110
+ this._clearListeners();
111
+ this._originalLinks.forEach(({ active, current }, link) => {
112
+ link.classList.toggle("active", active);
113
+ if (current === null) link.removeAttribute("aria-current");
114
+ else link.setAttribute("aria-current", current);
115
+ });
116
+ this._originalStatus.forEach((text, node) => {
117
+ node.textContent = text;
118
+ });
119
+ const { attribute, properties } = this._originalMarker;
120
+ if (attribute === null) this._element.removeAttribute("data-toc-marker");
121
+ else this._element.setAttribute("data-toc-marker", attribute);
122
+ properties.forEach(({ name, value, priority }) => {
123
+ if (value) this._element.style.setProperty(name, value, priority);
124
+ else this._element.style.removeProperty(name);
125
+ });
126
+ this._originalLinks.clear();
127
+ this._originalStatus.clear();
128
+ this._targets = [];
129
+ this._links = [];
130
+ if (instances.get(this._element) === this) instances.delete(this._element);
131
+ }
132
+ _listen(target, type, handler, options) {
133
+ target.addEventListener(type, handler, options);
134
+ this._listeners.push({ target, type, handler, options });
135
+ }
136
+ _clearListeners() {
137
+ this._listeners.forEach(({ target, type, handler, options }) => target.removeEventListener(type, handler, options));
138
+ this._listeners = [];
139
+ if (this._frame) this._window.cancelAnimationFrame(this._frame);
140
+ this._frame = 0;
141
+ }
142
+ _requestUpdate() {
143
+ if (this._disposed || this._frame) return;
144
+ this._frame = this._window.requestAnimationFrame(() => {
145
+ this._frame = 0;
146
+ this._update();
147
+ });
148
+ }
149
+ _update() {
150
+ if (this._disposed) return;
151
+ const targets = this._targets.filter(({ target }) => target.isConnected && target.getClientRects().length > 0);
152
+ const scroller = this._scrollElement || this._document.scrollingElement;
153
+ const line = (this._scrollElement ? this._scrollElement.getBoundingClientRect().top + this._scrollElement.clientTop : 0) + this._offset;
154
+ let active = null;
155
+ targets.forEach((entry) => {
156
+ if (entry.target.getBoundingClientRect().top <= line + 1) active = entry;
157
+ });
158
+ const canScroll = scroller && scroller.scrollHeight > scroller.clientHeight + 1;
159
+ if (!canScroll) {
160
+ const hashId = fragmentId(this._window.location.hash);
161
+ active = targets.find(({ id }) => id === hashId) || active;
162
+ }
163
+ if (targets.length && canScroll && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1) {
164
+ active = targets[targets.length - 1];
165
+ }
166
+ this._activate(active);
167
+ }
168
+ _activate(active) {
169
+ let activeLink = null;
170
+ this._links.forEach(({ link, id }) => {
171
+ const current = active !== null && id === active.id;
172
+ link.classList.toggle("active", current);
173
+ if (current) {
174
+ link.setAttribute("aria-current", "location");
175
+ activeLink || (activeLink = link);
176
+ } else link.removeAttribute("aria-current");
177
+ });
178
+ if (this._status) this._status.textContent = active ? active.label : this._overview;
179
+ this._updateMarker(activeLink);
180
+ }
181
+ _updateMarker(link) {
182
+ const list = this._element.querySelector(":scope > .nav");
183
+ const text = link?.querySelector("span");
184
+ if (!list?.contains(link) || !text?.offsetHeight || !this._element.getClientRects().length) {
185
+ this._element.removeAttribute("data-toc-marker");
186
+ return;
187
+ }
188
+ this._element.style.setProperty("--moo-toc-marker-offset", `${link.offsetTop + text.offsetTop}px`);
189
+ this._element.style.setProperty("--moo-toc-marker-height", `${text.offsetHeight}px`);
190
+ this._element.setAttribute("data-toc-marker", "");
191
+ }
192
+ }
193
+ export {
194
+ TableOfContents as default
195
+ };
@@ -3,22 +3,22 @@
3
3
  {
4
4
  "export": "./moo.css",
5
5
  "path": "dist/assets/css/moo.css",
6
- "sha256": "32108d9a95122382228038cd4fe4ca48736a8c0b6219375a08e7697d96d9abd3"
6
+ "sha256": "38bb85c89e2f1f2f043119a9290cb58dd74a1572090b5fc41930c7c57a90c894"
7
7
  },
8
8
  {
9
9
  "export": "./moo-ui.css",
10
10
  "path": "dist/assets/css/moo-ui.css",
11
- "sha256": "5824bc488463bc6e6d967e4079b30d8053c9d67cba01479c10cde3830865a1bb"
11
+ "sha256": "d6947a3a27a4315591cf9f6b92e1fd1d958a5fceb1523b709487d54fcbefc815"
12
12
  },
13
13
  {
14
14
  "export": "./state.js",
15
15
  "path": "dist/js/state.js",
16
- "sha256": "85b12aea0f22f1a29c9ffe791b0d53d53cc7ab55b08ede10029edc558c8786a6"
16
+ "sha256": "3560c86c8623a3d523ec763456d984e009d8a7fa5834de168b153ba9d242f1c2"
17
17
  }
18
18
  ],
19
19
  "package": {
20
20
  "name": "@wpmoo/ui",
21
- "version": "1.0.0"
21
+ "version": "1.1.0-dev.1"
22
22
  },
23
23
  "schemaVersion": 1
24
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/ui",
3
- "version": "1.0.0",
3
+ "version": "1.1.0-dev.1",
4
4
  "description": "Bootstrap markup. shadcn feel. A frontend-framework-free component system for server-rendered apps.",
5
5
  "license": "MIT",
6
6
  "author": "WPMoo (https://wpmoo.org)",
@@ -38,6 +38,7 @@
38
38
  "./context-menu.js": "./dist/js/context-menu.js",
39
39
  "./datatable.js": "./dist/js/datatable.js",
40
40
  "./slider.js": "./dist/js/slider.js",
41
+ "./table-of-contents.js": "./dist/js/table-of-contents.js",
41
42
  "./sheet.js": "./dist/js/sheet.js",
42
43
  "./chart.js": "./dist/js/chart.js",
43
44
  "./chart.min.js": "./dist/js/chart.min.js",
@@ -65,6 +66,7 @@
65
66
  "dist/js/context-menu.js",
66
67
  "dist/js/datatable.js",
67
68
  "dist/js/slider.js",
69
+ "dist/js/table-of-contents.js",
68
70
  "dist/js/sheet.js",
69
71
  "dist/js/chart.js",
70
72
  "dist/js/chart.min.js",
@@ -42,6 +42,7 @@
42
42
  @import "components/menubar";
43
43
  @import "components/avatar";
44
44
  @import "components/navigation";
45
+ @import "components/toc";
45
46
  @import "components/skeleton";
46
47
  @import "components/sidebar";
47
48
  @import "components/alert";
@@ -0,0 +1,48 @@
1
+ /* Table of contents */
2
+ [data-toc] > .fw-semibold {
3
+ padding-inline-start: calc(#{$nav-link-padding-x} - var(--bs-border-width));
4
+ }
5
+
6
+ [data-toc]:has(> .nav) {
7
+ position: relative;
8
+ border-inline-start: calc(var(--bs-border-width) * 0.5) solid var(--bs-border-color);
9
+ }
10
+
11
+ [data-toc][data-toc-marker]::after {
12
+ content: "";
13
+ position: absolute;
14
+ inset-block-start: 0;
15
+ inset-inline-start: calc(var(--bs-border-width) * -1);
16
+ block-size: var(--moo-toc-marker-height);
17
+ border-inline-start: calc(var(--bs-border-width) * 3) solid var(--moo-primary);
18
+ transform: translateY(var(--moo-toc-marker-offset));
19
+ pointer-events: none;
20
+ @include transition($transition-base);
21
+ }
22
+
23
+ [data-toc] > .nav .nav-link {
24
+ display: flex;
25
+ position: relative;
26
+ margin-inline-start: calc(var(--bs-border-width) * -1);
27
+ color: var(--moo-muted-foreground);
28
+ }
29
+
30
+ [data-toc] > .nav .nav-link::before {
31
+ content: "";
32
+ position: absolute;
33
+ inset-block: var(--bs-nav-link-padding-y);
34
+ inset-inline-start: 0;
35
+ border-inline-start: calc(var(--bs-border-width) * 3) solid transparent;
36
+ }
37
+
38
+ [data-toc] > .nav .nav-link.active {
39
+ color: var(--moo-foreground);
40
+ }
41
+
42
+ [data-toc] > .nav .nav-link.active::before {
43
+ border-inline-start-color: var(--moo-primary);
44
+ }
45
+
46
+ [data-toc][data-toc-marker] > .nav .nav-link.active::before {
47
+ border-inline-start-color: transparent;
48
+ }
@@ -5,6 +5,7 @@
5
5
  /* Utilities */
6
6
  @import "../utilities/border";
7
7
  @import "../utilities/scroll_fade";
8
+ @import "../utilities/scroll_smooth";
8
9
  /* Application layout */
9
10
  @import "../layouts/app";
10
11
  @import "../layouts/page_grid";
@@ -35,10 +35,6 @@
35
35
  transition: background 0.15s ease;
36
36
  }
37
37
 
38
- .sidebar-rail:hover::after {
39
- background: var(--moo-sidebar-border);
40
- }
41
-
42
38
  .wrapper[data-layout="app"][data-shell-mode="contained"] .sidebar-inner {
43
39
  position: relative;
44
40
  height: 100%;
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Moo UI v1.0.0 (https://wpmoo.org/)
2
+ * Moo UI v1.1.0-dev.1 (https://wpmoo.org/)
3
3
  * Copyright 2026 WPMoo Authors
4
4
  * Licensed under MIT (https://github.com/wpmoo-org/ui/blob/main/LICENSE)
5
5
  *
@@ -0,0 +1,9 @@
1
+ .scroll-smooth {
2
+ scroll-behavior: smooth;
3
+ }
4
+
5
+ @media (prefers-reduced-motion: reduce) {
6
+ .scroll-smooth {
7
+ scroll-behavior: auto;
8
+ }
9
+ }