@wpmoo/ui 0.3.1 → 0.4.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.
@@ -0,0 +1,490 @@
1
+ const instances = new WeakMap();
2
+
3
+ export default class Sidebar {
4
+ static getInstance(element) {
5
+ return element?.nodeType === 1 ? instances.get(element) || null : null;
6
+ }
7
+
8
+ static getOrCreateInstance(element, config = {}) {
9
+ return Sidebar.getInstance(element) || new Sidebar(element, config);
10
+ }
11
+
12
+ constructor(element, config = {}) {
13
+ if (element?.nodeType !== 1 || !element.matches('[data-slot="sidebar-wrapper"]')) {
14
+ throw new TypeError("Sidebar requires a [data-slot=sidebar-wrapper] root.");
15
+ }
16
+ const existing = instances.get(element);
17
+ if (existing) {
18
+ return existing;
19
+ }
20
+
21
+ this._element = element;
22
+ this._document = element.ownerDocument;
23
+ this._window = this._document.defaultView;
24
+ this._root = this._document.documentElement;
25
+ this._sidebar = element.querySelector('[data-slot="sidebar"]');
26
+ this._config = {
27
+ breakpoint: "(min-width: 992px)",
28
+ storagePrefix: "moo-sidebar:",
29
+ keyboard: true,
30
+ ...config,
31
+ };
32
+ this._listeners = [];
33
+ this._tooltipAnchors = new Set();
34
+ this._flyout = null;
35
+ this._flyoutOwner = null;
36
+ this._offcanvas = null;
37
+ this._offcanvasTrigger = null;
38
+
39
+ instances.set(element, this);
40
+ this._bindEvents();
41
+ this._restoreState();
42
+ this._element.setAttribute("data-moo-sidebar-ready", "");
43
+ this._observeDirection();
44
+ }
45
+
46
+ dispose() {
47
+ this._listeners.forEach(({ target, type, handler, options }) => {
48
+ target.removeEventListener(type, handler, options);
49
+ });
50
+ this._listeners = [];
51
+ this._directionObserver?.disconnect();
52
+ this._closeFlyouts();
53
+ this._element
54
+ .querySelectorAll("[data-moo-sidebar-dropdown-positioned]")
55
+ .forEach((item) => this._clearDropdownPosition(item));
56
+ this._element
57
+ .querySelectorAll("[data-moo-sidebar-tooltip]")
58
+ .forEach((control) => this._disposeTooltip(control));
59
+ this._offcanvas?.dispose();
60
+ this._offcanvas = null;
61
+ this._offcanvasTrigger = null;
62
+ this._element.removeAttribute("data-moo-sidebar-ready");
63
+ instances.delete(this._element);
64
+ }
65
+
66
+ _listen(target, type, handler, options) {
67
+ target?.addEventListener(type, handler, options);
68
+ if (target) {
69
+ this._listeners.push({ target, type, handler, options });
70
+ }
71
+ }
72
+
73
+ _bootstrap(name) {
74
+ return this._window.bootstrap?.[name] || null;
75
+ }
76
+
77
+ _isDesktop() {
78
+ return this._window.matchMedia(this._config.breakpoint).matches;
79
+ }
80
+
81
+ _isCollapsed() {
82
+ return this._isDesktop() && this._element.dataset.mooSidebarState === "collapsed";
83
+ }
84
+
85
+ _trigger(name, detail = {}) {
86
+ return this._element.dispatchEvent(
87
+ new this._window.CustomEvent(`${name}.moo.sidebar`, {
88
+ bubbles: true,
89
+ cancelable: name === "show" || name === "hide",
90
+ detail,
91
+ })
92
+ );
93
+ }
94
+
95
+ _observeDirection() {
96
+ const Observer = this._window.MutationObserver;
97
+ if (!Observer) {
98
+ return;
99
+ }
100
+ this._directionObserver = new Observer(() => {
101
+ this._closeFlyouts();
102
+ this._syncTooltips();
103
+ });
104
+ this._directionObserver.observe(this._root, {
105
+ attributes: true,
106
+ attributeFilter: ["dir"],
107
+ });
108
+ }
109
+
110
+ _restoreState() {
111
+ const key = this._element.dataset.mooSidebarKey;
112
+ let stored = null;
113
+ if (key) {
114
+ try {
115
+ stored = this._window.localStorage.getItem(this._config.storagePrefix + key);
116
+ } catch (_) {
117
+ stored = null;
118
+ }
119
+ }
120
+ const initial =
121
+ stored === "collapsed" || stored === "expanded"
122
+ ? stored
123
+ : this._element.dataset.mooSidebarState === "collapsed"
124
+ ? "collapsed"
125
+ : "expanded";
126
+ this._setState(initial, false, false);
127
+ }
128
+
129
+ _setState(state, persist = true, emit = true) {
130
+ const next = state === "collapsed" ? "collapsed" : "expanded";
131
+ const previous = this._element.dataset.mooSidebarState;
132
+ this._element.dataset.mooSidebarState = next;
133
+ const key = this._element.dataset.mooSidebarKey;
134
+ if (key === "catalog-shell") {
135
+ this._root.dataset.mooSidebarCatalogState = next;
136
+ }
137
+ if (persist && key) {
138
+ try {
139
+ this._window.localStorage.setItem(this._config.storagePrefix + key, next);
140
+ } catch (_) {
141
+ /* Storage is best-effort in restricted browsing contexts. */
142
+ }
143
+ }
144
+ this._syncControls();
145
+ if (next === "expanded") {
146
+ this._element
147
+ .querySelectorAll(".sidebar-menu-item")
148
+ .forEach((item) => this._resetFlyoutTrigger(item));
149
+ }
150
+ this._syncTooltips();
151
+ if (emit && previous !== next) {
152
+ this._trigger("change", { state: next, previousState: previous || null });
153
+ }
154
+ }
155
+
156
+ _toggle() {
157
+ this._setState(
158
+ this._element.dataset.mooSidebarState === "collapsed" ? "expanded" : "collapsed"
159
+ );
160
+ }
161
+
162
+ _syncControls() {
163
+ const expanded = this._isDesktop()
164
+ ? this._element.dataset.mooSidebarState === "expanded"
165
+ : this._sidebar?.classList.contains("show") || false;
166
+ this._element
167
+ .querySelectorAll("[data-moo-sidebar-trigger], [data-moo-sidebar-rail]")
168
+ .forEach((control) => control.setAttribute("aria-expanded", String(expanded)));
169
+ }
170
+
171
+ _resetFlyoutTrigger(item, expanded = null) {
172
+ const trigger = item?.querySelector(":scope > .sidebar-menu-sub-trigger");
173
+ const submenu = item?.querySelector(":scope > .sidebar-menu-sub");
174
+ if (trigger && submenu) {
175
+ trigger.setAttribute(
176
+ "aria-expanded",
177
+ String(expanded ?? submenu.classList.contains("show"))
178
+ );
179
+ }
180
+ }
181
+
182
+ _clearDropdownPosition(control) {
183
+ const item = control?.matches?.(".sidebar-menu-item")
184
+ ? control
185
+ : control?.closest?.(".sidebar-menu-item");
186
+ if (!item) {
187
+ return;
188
+ }
189
+ delete item.dataset.mooSidebarDropdownPositioned;
190
+ item.style.removeProperty("--moo-sidebar-dropdown-block-start");
191
+ item.style.removeProperty("--moo-sidebar-dropdown-inline-start");
192
+ }
193
+
194
+ _closeDropdowns(exceptControl = null) {
195
+ const Dropdown = this._bootstrap("Dropdown");
196
+ this._element
197
+ .querySelectorAll('[data-bs-toggle="dropdown"][aria-expanded="true"]')
198
+ .forEach((control) => {
199
+ if (control === exceptControl) {
200
+ return;
201
+ }
202
+ if (Dropdown) {
203
+ Dropdown.getOrCreateInstance(control).hide();
204
+ } else {
205
+ control.setAttribute("aria-expanded", "false");
206
+ control
207
+ .closest(".dropdown")
208
+ ?.querySelector(".dropdown-menu.show")
209
+ ?.classList.remove("show");
210
+ this._clearDropdownPosition(control);
211
+ }
212
+ });
213
+ }
214
+
215
+ _positionDropdown(control) {
216
+ const item = control?.closest?.(".sidebar-menu-item");
217
+ const isHeaderWorkspace =
218
+ control?.classList.contains("sidebar-menu-button--workspace") &&
219
+ control.closest('[data-slot="sidebar-header"]');
220
+ if (!control || !item || !isHeaderWorkspace || !this._isDesktop()) {
221
+ this._clearDropdownPosition(control);
222
+ return;
223
+ }
224
+
225
+ this._closeFlyouts();
226
+ const rect = control.getBoundingClientRect();
227
+ const gap = 4;
228
+ const inlineStart =
229
+ this._root.dir === "rtl"
230
+ ? this._window.innerWidth - rect.left + gap
231
+ : rect.right + gap;
232
+ item.style.setProperty(
233
+ "--moo-sidebar-dropdown-block-start",
234
+ `${Math.round(rect.bottom + gap)}px`
235
+ );
236
+ item.style.setProperty(
237
+ "--moo-sidebar-dropdown-inline-start",
238
+ `${Math.round(inlineStart)}px`
239
+ );
240
+ item.dataset.mooSidebarDropdownPositioned = "";
241
+ }
242
+
243
+ _removeFlyoutPortal() {
244
+ this._flyout?.remove();
245
+ if (this._flyoutOwner) {
246
+ this._resetFlyoutTrigger(this._flyoutOwner, false);
247
+ }
248
+ this._flyout = null;
249
+ this._flyoutOwner = null;
250
+ }
251
+
252
+ _closeFlyouts() {
253
+ this._removeFlyoutPortal();
254
+ this._element.querySelectorAll(".sidebar-menu-item--flyout-open").forEach((item) => {
255
+ item.classList.remove("sidebar-menu-item--flyout-open");
256
+ item.style.removeProperty("--moo-sidebar-flyout-block-start");
257
+ item.style.removeProperty("--moo-sidebar-flyout-inline-start");
258
+ this._resetFlyoutTrigger(item, false);
259
+ });
260
+ }
261
+
262
+ _openFlyout(item) {
263
+ const submenu = item?.querySelector(":scope > .sidebar-menu-sub");
264
+ const trigger = item?.querySelector(":scope > .sidebar-menu-sub-trigger");
265
+ if (!item || !submenu || !this._isCollapsed()) {
266
+ return;
267
+ }
268
+ if (this._flyoutOwner === item && this._flyout) {
269
+ return;
270
+ }
271
+
272
+ this._closeDropdowns();
273
+ this._closeFlyouts();
274
+ const rect = item.getBoundingClientRect();
275
+ const gap = 4;
276
+ const inlineStart =
277
+ this._root.dir === "rtl"
278
+ ? this._window.innerWidth - rect.left + gap
279
+ : rect.right + gap;
280
+ const flyout = submenu.cloneNode(true);
281
+ flyout.removeAttribute("id");
282
+ flyout.classList.remove("collapse", "show", "collapsing");
283
+ flyout.classList.add("sidebar-menu-flyout");
284
+ flyout.dataset.mooSidebarFlyout = "";
285
+ flyout.removeAttribute("style");
286
+ flyout.style.setProperty("--moo-sidebar-flyout-block-start", `${Math.round(rect.top)}px`);
287
+ flyout.style.setProperty(
288
+ "--moo-sidebar-flyout-inline-start",
289
+ `${Math.round(inlineStart)}px`
290
+ );
291
+ const root = this._element.closest(".moo-ui") || this._document.body;
292
+ root.appendChild(flyout);
293
+ this._flyout = flyout;
294
+ this._flyoutOwner = item;
295
+ item.classList.add("sidebar-menu-item--flyout-open");
296
+ trigger?.setAttribute("aria-expanded", "true");
297
+ }
298
+
299
+ _tooltipAnchor(control) {
300
+ return control.closest("li") || control;
301
+ }
302
+
303
+ _isIdentityTrigger(control) {
304
+ return (
305
+ control.closest(".sidebar-menu-item--account") ||
306
+ control.classList.contains("sidebar-menu-button--workspace")
307
+ );
308
+ }
309
+
310
+ _disposeTooltip(control) {
311
+ const Tooltip = this._bootstrap("Tooltip");
312
+ if (!Tooltip || !control) {
313
+ return;
314
+ }
315
+ const anchor = this._tooltipAnchor(control);
316
+ Tooltip.getInstance(anchor)?.dispose();
317
+ this._tooltipAnchors.delete(anchor);
318
+ anchor.removeAttribute("title");
319
+ anchor.removeAttribute("data-bs-title");
320
+ anchor.removeAttribute("data-bs-original-title");
321
+ anchor.removeAttribute("aria-describedby");
322
+ }
323
+
324
+ _syncTooltips() {
325
+ const Tooltip = this._bootstrap("Tooltip");
326
+ if (!Tooltip) {
327
+ return;
328
+ }
329
+ const collapsed = this._isCollapsed();
330
+ const placement = this._root.dir === "rtl" ? "left" : "right";
331
+ this._element.querySelectorAll("[data-moo-sidebar-tooltip]").forEach((control) => {
332
+ this._disposeTooltip(control);
333
+ if (
334
+ !collapsed ||
335
+ this._isIdentityTrigger(control) ||
336
+ control.closest(".sidebar-menu-item")?.querySelector(":scope > .sidebar-menu-sub")
337
+ ) {
338
+ return;
339
+ }
340
+
341
+ // Bootstrap permits one plugin instance per element. A dropdown or
342
+ // collapse trigger keeps that instance slot; its neutral <li> owns the
343
+ // state-driven Sidebar tooltip instead.
344
+ const anchor = this._tooltipAnchor(control);
345
+ new Tooltip(anchor, {
346
+ title: control.getAttribute("data-moo-sidebar-tooltip"),
347
+ placement,
348
+ container: "body",
349
+ trigger: "hover focus",
350
+ });
351
+ this._tooltipAnchors.add(anchor);
352
+ });
353
+ }
354
+
355
+ _bindEvents() {
356
+ this._listen(this._sidebar, "shown.bs.offcanvas", () => this._syncControls());
357
+ this._listen(this._sidebar, "hidden.bs.offcanvas", () => {
358
+ this._syncControls();
359
+ const trigger = this._offcanvasTrigger;
360
+ this._offcanvasTrigger = null;
361
+ if (trigger?.isConnected) {
362
+ trigger.focus();
363
+ }
364
+ });
365
+ this._listen(this._document, "show.bs.dropdown", (event) => {
366
+ const control = this._dropdownControl(event.target);
367
+ if (!control) {
368
+ return;
369
+ }
370
+ this._closeDropdowns(control);
371
+ this._disposeTooltip(control);
372
+ this._positionDropdown(control);
373
+ });
374
+ this._listen(this._document, "hidden.bs.dropdown", (event) => {
375
+ const control = this._dropdownControl(event.target);
376
+ if (!control) {
377
+ return;
378
+ }
379
+ this._clearDropdownPosition(control);
380
+ this._syncTooltips();
381
+ });
382
+ this._listen(this._element, "click", (event) => this._handleControlClick(event));
383
+ this._listen(this._window, "click", (event) => this._handleSubmenuClick(event), true);
384
+ this._listen(this._document, "click", (event) => this._handleOutsideFlyoutClick(event));
385
+ this._listen(this._document, "keydown", (event) => this._handleShortcut(event));
386
+ this._listen(this._window, "resize", () => {
387
+ this._closeFlyouts();
388
+ this._element
389
+ .querySelectorAll("[data-moo-sidebar-dropdown-positioned]")
390
+ .forEach((item) => this._clearDropdownPosition(item));
391
+ this._syncControls();
392
+ this._syncTooltips();
393
+ });
394
+ }
395
+
396
+ _dropdownControl(target) {
397
+ if (!(target instanceof this._window.Element) || !this._element.contains(target)) {
398
+ return null;
399
+ }
400
+ if (target.matches('[data-bs-toggle="dropdown"][data-moo-sidebar-tooltip]')) {
401
+ return target;
402
+ }
403
+ return target.querySelector?.(
404
+ '[data-bs-toggle="dropdown"][data-moo-sidebar-tooltip]'
405
+ ) || null;
406
+ }
407
+
408
+ _handleControlClick(event) {
409
+ const target = event.target;
410
+ const control = target instanceof this._window.Element
411
+ ? target.closest("[data-moo-sidebar-trigger], [data-moo-sidebar-rail]")
412
+ : null;
413
+ if (!control || !this._element.contains(control)) {
414
+ return;
415
+ }
416
+ if (this._isDesktop()) {
417
+ event.preventDefault();
418
+ this._closeFlyouts();
419
+ this._toggle();
420
+ return;
421
+ }
422
+ if (!control.matches("[data-moo-sidebar-trigger]")) {
423
+ return;
424
+ }
425
+ const sidebar = this._document.getElementById(control.getAttribute("aria-controls"));
426
+ const Offcanvas = this._bootstrap("Offcanvas");
427
+ if (sidebar && Offcanvas) {
428
+ this._offcanvasTrigger = control;
429
+ this._offcanvas = Offcanvas.getOrCreateInstance(sidebar);
430
+ this._offcanvas.toggle();
431
+ }
432
+ }
433
+
434
+ _handleSubmenuClick(event) {
435
+ const target = event.target;
436
+ const trigger = target instanceof this._window.Element
437
+ ? target.closest(".sidebar-menu-sub-trigger")
438
+ : null;
439
+ const item = trigger?.closest(".sidebar-menu-item");
440
+ if (!trigger || !item || !this._element.contains(item) || !this._isCollapsed()) {
441
+ return;
442
+ }
443
+ event.preventDefault();
444
+ event.stopImmediatePropagation();
445
+ if (this._flyoutOwner === item && this._flyout) {
446
+ this._closeFlyouts();
447
+ } else {
448
+ this._openFlyout(item);
449
+ }
450
+ }
451
+
452
+ _handleOutsideFlyoutClick(event) {
453
+ if (!this._flyout) {
454
+ return;
455
+ }
456
+ if (this._flyout.contains(event.target)) {
457
+ this._closeFlyouts();
458
+ } else if (!this._flyoutOwner?.contains(event.target)) {
459
+ this._closeFlyouts();
460
+ }
461
+ }
462
+
463
+ _handleShortcut(event) {
464
+ const target = event.target;
465
+ const isEditable =
466
+ target instanceof this._window.Element &&
467
+ (target.matches("input, textarea, select") || target.isContentEditable);
468
+ if (
469
+ event.defaultPrevented ||
470
+ event.isComposing ||
471
+ isEditable ||
472
+ !this._config.keyboard ||
473
+ !(event.metaKey || event.ctrlKey) ||
474
+ event.key.toLowerCase() !== "b" ||
475
+ !this._isDesktop()
476
+ ) {
477
+ return;
478
+ }
479
+ const preferred =
480
+ this._document.querySelector(
481
+ '[data-slot="sidebar-wrapper"][data-moo-sidebar-key]'
482
+ ) || this._document.querySelector('[data-slot="sidebar-wrapper"]');
483
+ if (preferred !== this._element) {
484
+ return;
485
+ }
486
+ event.preventDefault();
487
+ this._closeFlyouts();
488
+ this._toggle();
489
+ }
490
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/ui",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Bootstrap-native HTML component system with a focused, modern visual language.",
5
5
  "license": "MIT",
6
6
  "author": "WPMoo (https://wpmoo.org)",
@@ -13,6 +13,7 @@
13
13
  "url": "https://github.com/wpmoo-org/ui/issues"
14
14
  },
15
15
  "private": false,
16
+ "type": "module",
16
17
  "scripts": {
17
18
  "build": ".venv/bin/python build.py",
18
19
  "dev": ".venv/bin/python dev.py",
@@ -26,6 +27,8 @@
26
27
  "./moo-ui.min.css": "./dist/assets/css/moo-ui.min.css",
27
28
  "./moo.css": "./dist/assets/css/moo.css",
28
29
  "./moo.min.css": "./dist/assets/css/moo.min.css",
30
+ "./combobox.js": "./dist/js/combobox.js",
31
+ "./sidebar.js": "./dist/js/sidebar.js",
29
32
  "./package.json": "./package.json"
30
33
  },
31
34
  "files": [
@@ -33,6 +36,8 @@
33
36
  "dist/assets/css/moo-ui.min.css",
34
37
  "dist/assets/css/moo.css",
35
38
  "dist/assets/css/moo.min.css",
39
+ "dist/js/combobox.js",
40
+ "dist/js/sidebar.js",
36
41
  "README.md",
37
42
  "LICENSE",
38
43
  "ASSET_LICENSE.md",