@wpmoo/ui 0.9.0 → 1.0.0-rc.2

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.
@@ -279,8 +279,26 @@ export default class Combobox {
279
279
  this._input.value = "";
280
280
  }
281
281
 
282
+ _activeOption() {
283
+ const activeId = this._input.getAttribute("aria-activedescendant");
284
+ if (!activeId) {
285
+ return null;
286
+ }
287
+ return this._visibleOptions().find((option) => option.id === activeId) || null;
288
+ }
289
+
290
+ _activateFirstOptionIfNeeded() {
291
+ if (!this._activeOption()) {
292
+ this._setActiveOption(this._visibleOptions()[0] || null);
293
+ }
294
+ }
295
+
282
296
  _setActiveOption(option) {
283
- this._options.forEach((candidate) => candidate.toggleAttribute("aria-current", candidate === option));
297
+ this._options.forEach((candidate) => {
298
+ const active = candidate === option;
299
+ candidate.toggleAttribute("aria-current", active);
300
+ candidate.classList.toggle("active", active);
301
+ });
284
302
  if (!option) {
285
303
  this._input.removeAttribute("aria-activedescendant");
286
304
  return;
@@ -338,7 +356,7 @@ export default class Combobox {
338
356
  this._empty.hidden = count !== 0;
339
357
  this._liveRegion.textContent = count === 0 ? "No results" : `${count} result${count === 1 ? "" : "s"}`;
340
358
  if (activate) {
341
- this._setActiveOption(this._visibleOptions()[0] || null);
359
+ this._activateFirstOptionIfNeeded();
342
360
  }
343
361
  }
344
362
 
@@ -348,7 +366,7 @@ export default class Combobox {
348
366
  if (!this._isMultiple && this._input.dataset.mooComboboxSelected === "true") {
349
367
  this._input.select();
350
368
  }
351
- this._setActiveOption(this._visibleOptions()[0] || null);
369
+ this._activateFirstOptionIfNeeded();
352
370
  });
353
371
  this._listen(this._input, "input", () => {
354
372
  if (!this._isMultiple) {
@@ -356,10 +374,20 @@ export default class Combobox {
356
374
  }
357
375
  this._filterOptions();
358
376
  });
359
- this._listen(this._input, "blur", () => this._clearStaleSelection());
360
- this._listen(this._input, "click", () => this._openMenu());
377
+ this._listen(this._input, "blur", (event) => {
378
+ const nextTarget = event.relatedTarget;
379
+ if (nextTarget instanceof this._window.Node && this._element.contains(nextTarget)) {
380
+ return;
381
+ }
382
+ this._clearStaleSelection();
383
+ });
384
+ this._listen(this._input, "click", () => {
385
+ this._openMenu();
386
+ this._setActiveOption(this._visibleOptions()[0] || null);
387
+ });
361
388
  this._listen(this._input, "keydown", (event) => this._handleKeydown(event));
362
389
  this._options.forEach((option) => {
390
+ this._listen(option, "keydown", (event) => this._handleKeydown(event));
363
391
  this._listen(option, "click", () => {
364
392
  this._chooseOption(option);
365
393
  if (this._isMultiple) {
@@ -407,6 +435,8 @@ export default class Combobox {
407
435
  const current = available.findIndex(
408
436
  (option) => option.id === this._input.getAttribute("aria-activedescendant")
409
437
  );
438
+ const isNextKey = event.key === "ArrowDown" || event.key === "Down";
439
+ const isPreviousKey = event.key === "ArrowUp" || event.key === "Up";
410
440
  if (this._isMultiple && event.key === "Backspace" && this._input.value === "") {
411
441
  const selected = this._selectedOptions();
412
442
  const last = selected[selected.length - 1];
@@ -414,12 +444,15 @@ export default class Combobox {
414
444
  event.preventDefault();
415
445
  this._removeChip(last.dataset.value || "");
416
446
  }
417
- } else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && available.length) {
447
+ } else if ((isNextKey || isPreviousKey) && available.length) {
418
448
  event.preventDefault();
419
449
  this._openMenu();
420
- const offset = event.key === "ArrowDown" ? 1 : -1;
450
+ const offset = isNextKey ? 1 : -1;
421
451
  const next = current === -1 ? 0 : (current + offset + available.length) % available.length;
422
452
  this._setActiveOption(available[next]);
453
+ if (event.target !== this._input) {
454
+ this._input.focus({ preventScroll: true });
455
+ }
423
456
  } else if (event.key === "Enter") {
424
457
  const option = available[current];
425
458
  if (option) {
@@ -431,7 +464,7 @@ export default class Combobox {
431
464
  }
432
465
  } else if (event.key === "Escape") {
433
466
  this._closeMenu();
434
- this._input.blur();
467
+ this._input.focus();
435
468
  } else if (event.key === "Tab") {
436
469
  this._closeMenu();
437
470
  }
@@ -174,6 +174,19 @@ export default class ContextMenu {
174
174
  this._openMethod = "pointer";
175
175
  }
176
176
 
177
+ _handleFallbackKeydown(event) {
178
+ if (event.key !== " " && event.key !== "Spacebar") {
179
+ return;
180
+ }
181
+ event.preventDefault();
182
+ event.stopPropagation();
183
+ if (this._menu.classList.contains("show")) {
184
+ this._dropdown.hide();
185
+ return;
186
+ }
187
+ this._open(this._fallback, null, "fallback-keyboard");
188
+ }
189
+
177
190
  _handleMenuClick(event) {
178
191
  const item = menuItemFromEvent(event);
179
192
  if (!item) {
@@ -217,6 +230,7 @@ export default class ContextMenu {
217
230
  this._listen(this._element, "contextmenu", (event) => this._handleContextMenu(event));
218
231
  this._listen(this._element, "keydown", (event) => this._handleKeydown(event));
219
232
  this._listen(this._fallback, "click", () => this._handleFallbackClick());
233
+ this._listen(this._fallback, "keydown", (event) => this._handleFallbackKeydown(event));
220
234
  this._listen(this._menu, "click", (event) => this._handleMenuClick(event));
221
235
  this._listen(this._fallback, "show.bs.dropdown", (event) => {
222
236
  if (!this._trigger("show", true)) {
@@ -231,7 +245,9 @@ export default class ContextMenu {
231
245
  // highlight instead of a focus ring competing with it. A keyboard-
232
246
  // driven open (Shift+F10/ContextMenu key) still lands on the first
233
247
  // item, matching that same native arrow-key-open convention.
234
- if (this._openMethod === "keyboard") {
248
+ if (this._openMethod === "fallback-keyboard") {
249
+ this._fallback.focus();
250
+ } else if (this._openMethod === "keyboard") {
235
251
  this._menuItems()[0]?.focus();
236
252
  } else {
237
253
  this._menu.focus();
@@ -47,6 +47,8 @@ export default class DataTable {
47
47
  this._searchTerm = "";
48
48
  this._tooltips = [];
49
49
  this._filterMode = element.dataset.datatableFilterMode || "inline";
50
+ this._reparentedRowMenus = new Map();
51
+ this._reparentedRowMenuByTrigger = new WeakMap();
50
52
 
51
53
  this._rows = Array.from(this._tbody.querySelectorAll(":scope > tr[data-datatable-row]")).map((tr, index) => ({
52
54
  element: tr,
@@ -63,9 +65,12 @@ export default class DataTable {
63
65
  this._render();
64
66
  this._initBulkTooltips();
65
67
  this._initViewToggle();
68
+ this._initRowActionDropdowns();
66
69
  }
67
70
 
68
71
  dispose() {
72
+ this._disposeRowActionDropdowns();
73
+ this._restoreRowActionMenus();
69
74
  this._listeners.forEach(({ target, type, handler, options }) => {
70
75
  target.removeEventListener(type, handler, options);
71
76
  });
@@ -87,6 +92,141 @@ export default class DataTable {
87
92
  this._listeners.push({ target, type, handler, options });
88
93
  }
89
94
 
95
+ // Row menus are authored next to their trigger so the public HTML stays
96
+ // inspectable, but when opened they are temporarily moved under body.
97
+ // That keeps the menu out of the scroll wrapper's layout math, so Safari
98
+ // cannot clip it at the rounded frame edge or shift the visible table
99
+ // slice while Popper positions it against the viewport.
100
+ // Table and card views both wrap row actions in .table-row-actions, but
101
+ // .table-responsive only contains the table view; card triggers must be
102
+ // selected separately so both views get the same fixed Popper config.
103
+ _rowActionTriggers() {
104
+ return this._element.querySelectorAll(
105
+ ".table-responsive .table-row-actions [data-bs-toggle=\"dropdown\"], [data-datatable-card] .table-row-actions [data-bs-toggle=\"dropdown\"]"
106
+ );
107
+ }
108
+
109
+ _initRowActionDropdowns() {
110
+ const Dropdown = this._bootstrap("Dropdown");
111
+ if (!Dropdown) {
112
+ return;
113
+ }
114
+ this._rowActionTriggers().forEach((trigger) => {
115
+ Dropdown.getOrCreateInstance(trigger, {
116
+ popperConfig: (defaultConfig) => ({
117
+ ...defaultConfig,
118
+ strategy: "fixed",
119
+ modifiers: [
120
+ ...(defaultConfig.modifiers || []),
121
+ {
122
+ name: "flip",
123
+ options: {
124
+ fallbackPlacements: ["top-end", "top-start", "bottom-end", "bottom-start"],
125
+ },
126
+ },
127
+ {
128
+ name: "preventOverflow",
129
+ options: {
130
+ boundary: "viewport",
131
+ padding: 8,
132
+ },
133
+ },
134
+ ],
135
+ }),
136
+ });
137
+ });
138
+ }
139
+
140
+ // dispose() alone leaves any open row-action Dropdown's show/aria-expanded/
141
+ // data-bs-popper state and Bootstrap instance behind; hide and dispose each
142
+ // one first so a subsequent re-init starts from a clean state.
143
+ _disposeRowActionDropdowns() {
144
+ const Dropdown = this._bootstrap("Dropdown");
145
+ if (!Dropdown) {
146
+ return;
147
+ }
148
+ this._rowActionTriggers().forEach((trigger) => {
149
+ const instance = Dropdown.getInstance(trigger);
150
+ if (!instance) {
151
+ return;
152
+ }
153
+ instance.hide();
154
+ instance.dispose();
155
+ });
156
+ }
157
+
158
+ _rowActionMenuForTrigger(trigger) {
159
+ if (!trigger?.closest?.(".table-row-actions")) {
160
+ return null;
161
+ }
162
+ return trigger.closest(".dropdown")?.querySelector(":scope > .dropdown-menu") || null;
163
+ }
164
+
165
+ _rowActionOwnerIdForTrigger(trigger) {
166
+ const rowId = trigger?.closest?.("tr[data-datatable-row]")?.id;
167
+ if (rowId) {
168
+ return rowId;
169
+ }
170
+ return trigger?.closest?.("[data-datatable-card]")?.getAttribute("data-datatable-card-for") || "";
171
+ }
172
+
173
+ _reparentRowActionMenu(trigger) {
174
+ const menu = this._rowActionMenuForTrigger(trigger);
175
+ if (!menu || this._reparentedRowMenus.has(menu)) {
176
+ return;
177
+ }
178
+ const ownerId = this._rowActionOwnerIdForTrigger(trigger);
179
+ if (ownerId) {
180
+ menu.setAttribute("data-datatable-row-action-owner", ownerId);
181
+ }
182
+ if (trigger.id) {
183
+ menu.setAttribute("data-datatable-row-action-trigger", trigger.id);
184
+ }
185
+ this._reparentedRowMenus.set(menu, {
186
+ parent: menu.parentNode,
187
+ nextSibling: menu.nextSibling,
188
+ trigger,
189
+ });
190
+ this._reparentedRowMenuByTrigger.set(trigger, menu);
191
+ this._document.body.appendChild(menu);
192
+ }
193
+
194
+ _restoreRowActionMenuForTrigger(trigger) {
195
+ const menu = this._reparentedRowMenuByTrigger.get(trigger) || this._rowActionMenuForTrigger(trigger);
196
+ if (menu) {
197
+ this._restoreRowActionMenu(menu);
198
+ }
199
+ }
200
+
201
+ _restoreRowActionMenu(menu) {
202
+ const original = this._reparentedRowMenus.get(menu);
203
+ if (!original) {
204
+ return;
205
+ }
206
+ const { parent, nextSibling, trigger } = original;
207
+ if (parent?.isConnected) {
208
+ if (nextSibling?.parentNode === parent) {
209
+ parent.insertBefore(menu, nextSibling);
210
+ } else {
211
+ parent.appendChild(menu);
212
+ }
213
+ } else {
214
+ menu.remove();
215
+ }
216
+ if (trigger) {
217
+ this._reparentedRowMenuByTrigger.delete(trigger);
218
+ }
219
+ menu.removeAttribute("data-datatable-row-action-owner");
220
+ menu.removeAttribute("data-datatable-row-action-trigger");
221
+ this._reparentedRowMenus.delete(menu);
222
+ }
223
+
224
+ _restoreRowActionMenus() {
225
+ Array.from(this._reparentedRowMenus.keys()).forEach((menu) => {
226
+ this._restoreRowActionMenu(menu);
227
+ });
228
+ }
229
+
90
230
  _bootstrap(name) {
91
231
  return this._window.bootstrap?.[name] || null;
92
232
  }
@@ -504,35 +644,62 @@ export default class DataTable {
504
644
  if (!template) {
505
645
  return;
506
646
  }
507
- template.parentNode
508
- .querySelectorAll("[data-datatable-page-number], [data-datatable-page-ellipsis]")
509
- .forEach((node) => node.remove());
510
647
 
511
648
  const pages = this._pageWindow(this._currentPage, pageCount);
512
- const fragment = this._document.createDocumentFragment();
513
- pages.forEach((page) => {
514
- const li = this._document.createElement("li");
515
- li.className = "page-item";
516
- if (page === "…") {
517
- li.dataset.datatablePageEllipsis = "";
518
- li.innerHTML = '<span class="page-link">…</span>';
519
- } else {
520
- li.dataset.datatablePageNumber = String(page);
521
- if (page === this._currentPage) {
522
- li.classList.add("active");
523
- li.setAttribute("aria-current", "page");
649
+ const existing = Array.from(
650
+ template.parentNode.querySelectorAll(
651
+ "[data-datatable-page-number], [data-datatable-page-ellipsis]"
652
+ )
653
+ );
654
+ const existingWindow = existing.map((node) =>
655
+ node.dataset.datatablePageEllipsis === undefined
656
+ ? Number(node.dataset.datatablePageNumber)
657
+ : "…"
658
+ );
659
+ // Same node-reuse rationale as the row-reorder skip above: rebuilding
660
+ // every page-number button on each click destroys and recreates the one
661
+ // the user just clicked, which blurs it (losing the focus ring and any
662
+ // keyboard position). The page window is unchanged on most clicks (any
663
+ // dataset small enough to show every page, or a prev/next inside an
664
+ // already-visible window), so only rebuild when the window itself
665
+ // differs; otherwise just resync which node is active below.
666
+ const windowChanged =
667
+ existingWindow.length !== pages.length ||
668
+ existingWindow.some((page, index) => page !== pages[index]);
669
+
670
+ if (windowChanged) {
671
+ existing.forEach((node) => node.remove());
672
+ const fragment = this._document.createDocumentFragment();
673
+ pages.forEach((page) => {
674
+ const li = this._document.createElement("li");
675
+ li.className = "page-item";
676
+ if (page === "…") {
677
+ li.dataset.datatablePageEllipsis = "";
678
+ li.innerHTML = '<span class="page-link">…</span>';
679
+ } else {
680
+ li.dataset.datatablePageNumber = String(page);
681
+ const button = this._document.createElement("button");
682
+ button.type = "button";
683
+ button.className = "page-link";
684
+ button.textContent = String(page);
685
+ button.setAttribute("aria-label", `Go to page ${page}`);
686
+ button.dataset.datatablePageGo = String(page);
687
+ li.appendChild(button);
524
688
  }
525
- const button = this._document.createElement("button");
526
- button.type = "button";
527
- button.className = "page-link";
528
- button.textContent = String(page);
529
- button.setAttribute("aria-label", `Go to page ${page}`);
530
- button.dataset.datatablePageGo = String(page);
531
- li.appendChild(button);
689
+ fragment.appendChild(li);
690
+ });
691
+ template.parentNode.insertBefore(fragment, template);
692
+ }
693
+
694
+ template.parentNode.querySelectorAll("[data-datatable-page-number]").forEach((li) => {
695
+ const isActive = Number(li.dataset.datatablePageNumber) === this._currentPage;
696
+ li.classList.toggle("active", isActive);
697
+ if (isActive) {
698
+ li.setAttribute("aria-current", "page");
699
+ } else {
700
+ li.removeAttribute("aria-current");
532
701
  }
533
- fragment.appendChild(li);
534
702
  });
535
- template.parentNode.insertBefore(fragment, template);
536
703
  }
537
704
 
538
705
  _renderPageSize() {
@@ -1040,11 +1207,13 @@ export default class DataTable {
1040
1207
  // no-op for as long as the dropdown stays open, however it gets
1041
1208
  // re-triggered.
1042
1209
  this._listen(this._element, "show.bs.dropdown", (event) => {
1210
+ this._reparentRowActionMenu(event.target);
1043
1211
  const tooltip = this._bootstrap("Tooltip")?.getInstance(event.target);
1044
1212
  tooltip?.hide();
1045
1213
  tooltip?.disable();
1046
1214
  });
1047
1215
  this._listen(this._element, "hidden.bs.dropdown", (event) => {
1216
+ this._restoreRowActionMenuForTrigger(event.target);
1048
1217
  this._bootstrap("Tooltip")?.getInstance(event.target)?.enable();
1049
1218
  if (event.target.matches("[data-datatable-filter-menu-trigger]")) {
1050
1219
  this._showFilterPickerPanel();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/ui",
3
- "version": "0.9.0",
3
+ "version": "1.0.0-rc.2",
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)",
@@ -74,7 +74,7 @@
74
74
  "access": "public"
75
75
  },
76
76
  "devDependencies": {
77
- "axe-core": "4.12.1",
78
- "wrangler": "^4.112.0"
77
+ "axe-core": "4.13.0",
78
+ "wrangler": "^4.120.0"
79
79
  }
80
80
  }
@@ -23,10 +23,10 @@
23
23
  // Brand colors — any valid Sass color value:
24
24
  // $primary Default: #171717
25
25
  // $secondary Default: #f4f4f5
26
- // $success Default: #16803c
27
- // $info Default: #0f6e8c
28
- // $warning Default: #a15c00
29
- // $danger Default: #dc2626
26
+ // $success Default: #047857
27
+ // $info Default: #0369a1
28
+ // $warning Default: #b45309
29
+ // $danger Default: #e7000b
30
30
  // $light Default: #fafafa
31
31
  // $dark Default: #18181b
32
32
  //
@@ -6,10 +6,10 @@
6
6
 
7
7
  $primary: #171717 !default;
8
8
  $secondary: #f4f4f5 !default;
9
- $success: #16803c !default;
10
- $info: #0f6e8c !default;
11
- $warning: #a15c00 !default;
12
- $danger: #dc2626 !default;
9
+ $success: #047857 !default;
10
+ $info: #0369a1 !default;
11
+ $warning: #b45309 !default;
12
+ $danger: #e7000b !default;
13
13
  $light: #fafafa !default;
14
14
  $dark: #18181b !default;
15
15