@threadlabs/looma 0.37.4 → 0.37.5

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 (124) hide show
  1. package/components/shared/active-item-reveal.js +74 -0
  2. package/components/shared/menu-navigation.js +3 -1
  3. package/components/ui-button/ui-button.html +13 -0
  4. package/components/ui-combobox/ui-combobox.js +3 -1
  5. package/components/ui-context-menu/ui-context-menu.js +4 -1
  6. package/components/ui-editor-link-form/ui-editor-link-form.js +3 -1
  7. package/components/ui-editor-mention-menu/ui-editor-mention-menu.js +12 -5
  8. package/components/ui-editor-slash-menu/ui-editor-slash-menu.js +12 -5
  9. package/components/ui-listbox/ui-listbox.js +3 -1
  10. package/components/ui-menu/ui-menu.js +5 -1
  11. package/components/ui-pagination/ui-pagination.html +74 -0
  12. package/components/ui-pagination/ui-pagination.js +19 -0
  13. package/components/ui-search-shell/ui-search-shell.js +7 -4
  14. package/dist/index.js +20 -19
  15. package/docs/catalog.json +323 -1
  16. package/docs/examples/ui-pagination/examples/01-numbered.behavior.js +11 -0
  17. package/package.json +1 -1
  18. package/styles/ui-button.css +12 -0
  19. package/styles/ui-pagination.css +1 -0
  20. package/vanilla/UiButton.d.ts +1 -0
  21. package/vanilla/UiButton.js +31 -31
  22. package/vanilla/UiPagination.d.ts +35 -0
  23. package/vanilla/UiPagination.js +202 -0
  24. package/vanilla/index.js +1 -0
  25. package/vue/UiButton.d.ts +6 -0
  26. package/vue/UiButton.vue +34 -0
  27. package/vue/UiPagination.d.ts +115 -0
  28. package/vue/UiPagination.js +2 -0
  29. package/vue/UiPagination.vue +598 -0
  30. package/vue/chunks/UiActionBar.js +1 -1
  31. package/vue/chunks/UiAffordanceScope.js +1 -1
  32. package/vue/chunks/UiAvatar.js +1 -1
  33. package/vue/chunks/UiAvatarGroup.js +1 -1
  34. package/vue/chunks/UiBadge.js +1 -1
  35. package/vue/chunks/UiBreadcrumbItem.js +1 -1
  36. package/vue/chunks/UiBreadcrumbs.js +1 -1
  37. package/vue/chunks/UiButton.js +30 -2
  38. package/vue/chunks/UiCallout.js +1 -1
  39. package/vue/chunks/UiCard.js +1 -1
  40. package/vue/chunks/UiCheckbox.js +1 -1
  41. package/vue/chunks/UiCluster.js +1 -1
  42. package/vue/chunks/UiColorPicker.js +1 -1
  43. package/vue/chunks/UiCombobox.js +1 -1
  44. package/vue/chunks/UiContainer.js +1 -1
  45. package/vue/chunks/UiContextMenu.js +1 -1
  46. package/vue/chunks/UiCover.js +1 -1
  47. package/vue/chunks/UiCurveEditor.js +1 -1
  48. package/vue/chunks/UiCycleButton.js +1 -1
  49. package/vue/chunks/UiDescriptionItem.js +1 -1
  50. package/vue/chunks/UiDescriptionList.js +1 -1
  51. package/vue/chunks/UiDialog.js +1 -1
  52. package/vue/chunks/UiDisclosure.js +1 -1
  53. package/vue/chunks/UiEditable.js +1 -1
  54. package/vue/chunks/UiEditorBlockActions.js +1 -1
  55. package/vue/chunks/UiEditorChipEditor.js +1 -1
  56. package/vue/chunks/UiEditorCodeLanguage.js +1 -1
  57. package/vue/chunks/UiEditorFootnoteEditor.js +1 -1
  58. package/vue/chunks/UiEditorImageToolbar.js +1 -1
  59. package/vue/chunks/UiEditorInsertTableGrid.js +1 -1
  60. package/vue/chunks/UiEditorLinkCard.js +1 -1
  61. package/vue/chunks/UiEditorLinkForm.js +1 -1
  62. package/vue/chunks/UiEditorMentionMenu.js +1 -1
  63. package/vue/chunks/UiEditorMentionMenuItem.js +1 -1
  64. package/vue/chunks/UiEditorSlashMenu.js +1 -1
  65. package/vue/chunks/UiEditorSlashMenuGroup.js +1 -1
  66. package/vue/chunks/UiEditorSlashMenuItem.js +1 -1
  67. package/vue/chunks/UiEditorSurface.js +1 -1
  68. package/vue/chunks/UiEditorTableContextMenu.js +1 -1
  69. package/vue/chunks/UiEditorTableOverlay.js +1 -1
  70. package/vue/chunks/UiEditorTableToolbar.js +1 -1
  71. package/vue/chunks/UiEditorToolbar.js +1 -1
  72. package/vue/chunks/UiFloatingActionButton.js +1 -1
  73. package/vue/chunks/UiFormField.js +1 -1
  74. package/vue/chunks/UiGrid.js +1 -1
  75. package/vue/chunks/UiIcon.js +1 -1
  76. package/vue/chunks/UiIconButton.js +1 -1
  77. package/vue/chunks/UiImage.js +1 -1
  78. package/vue/chunks/UiInput.js +1 -1
  79. package/vue/chunks/UiInputGroup.js +1 -1
  80. package/vue/chunks/UiLightEditor.js +1 -1
  81. package/vue/chunks/UiList.js +1 -1
  82. package/vue/chunks/UiListItem.js +1 -1
  83. package/vue/chunks/UiListbox.js +1 -1
  84. package/vue/chunks/UiMenu.js +1 -1
  85. package/vue/chunks/UiMenuGroup.js +1 -1
  86. package/vue/chunks/UiMenuItem.js +1 -1
  87. package/vue/chunks/UiMeter.js +1 -1
  88. package/vue/chunks/UiNavItem.js +1 -1
  89. package/vue/chunks/UiNumberInput.js +1 -1
  90. package/vue/chunks/UiPageHeader.js +1 -1
  91. package/vue/chunks/UiPagination.js +551 -0
  92. package/vue/chunks/UiPopover.js +1 -1
  93. package/vue/chunks/UiRadio.js +1 -1
  94. package/vue/chunks/UiRadioGroup.js +1 -1
  95. package/vue/chunks/UiReel.js +1 -1
  96. package/vue/chunks/UiScrollArea.js +1 -1
  97. package/vue/chunks/UiSearchResultRow.js +1 -1
  98. package/vue/chunks/UiSearchShell.js +1 -1
  99. package/vue/chunks/UiSection.js +1 -1
  100. package/vue/chunks/UiSelect.js +1 -1
  101. package/vue/chunks/UiSeparator.js +1 -1
  102. package/vue/chunks/UiSidebar.js +1 -1
  103. package/vue/chunks/UiSlider.js +1 -1
  104. package/vue/chunks/UiSpinner.js +1 -1
  105. package/vue/chunks/UiStack.js +1 -1
  106. package/vue/chunks/UiStatusMessage.js +1 -1
  107. package/vue/chunks/UiSwatch.js +1 -1
  108. package/vue/chunks/UiSwitch.js +1 -1
  109. package/vue/chunks/UiSwitcher.js +1 -1
  110. package/vue/chunks/UiTable.js +1 -1
  111. package/vue/chunks/UiTabs.js +1 -1
  112. package/vue/chunks/UiText.js +1 -1
  113. package/vue/chunks/UiTextarea.js +1 -1
  114. package/vue/chunks/UiToast.js +1 -1
  115. package/vue/chunks/UiToastRegion.js +1 -1
  116. package/vue/chunks/UiTooltip.js +1 -1
  117. package/vue/chunks/UiTopBar.js +1 -1
  118. package/vue/chunks/UiTree.js +1 -1
  119. package/vue/chunks/UiTreeItem.js +1 -1
  120. package/vue/chunks/_plugin-vue_export-helper.js +27 -2
  121. package/vue/components.css +22 -0
  122. package/vue/editor/index.js +139 -35
  123. package/vue/index.d.ts +1 -0
  124. package/vue/index.js +2 -1
@@ -0,0 +1,74 @@
1
+ // Reveal within the component's own scrollport; browser focus/scrollIntoView can move the page.
2
+ const pending = new WeakMap();
3
+
4
+ export function cancelActiveItemReveal(root) {
5
+ const current = pending.get(root);
6
+ if (!current) return;
7
+ root.ownerDocument.defaultView.cancelAnimationFrame(current.frame);
8
+ pending.delete(root);
9
+ }
10
+
11
+ function scrollport(root, item, view) {
12
+ for (let parent = item.parentElement; parent && root.contains(parent); parent = parent.parentElement) {
13
+ if (parent.scrollHeight > parent.clientHeight && /^(auto|scroll|hidden)$/.test(view.getComputedStyle(parent).overflowY)) return parent;
14
+ if (parent === root) break;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function destination(port, item, view) {
20
+ const bounds = port.getBoundingClientRect();
21
+ const scale = port.offsetHeight ? bounds.height / port.offsetHeight : 1;
22
+ if (!scale) return port.scrollTop;
23
+ const style = view.getComputedStyle(port);
24
+ const fade = style.maskImage !== "none" ? parseFloat(style.getPropertyValue("--_ui-scroll-fade-size")) || 0 : 0;
25
+ const top = bounds.top + (port.clientTop + Math.max(parseFloat(style.scrollPaddingTop) || 0, fade)) * scale;
26
+ const bottom = bounds.top + (port.clientTop + port.clientHeight - Math.max(parseFloat(style.scrollPaddingBottom) || 0, fade)) * scale;
27
+ const row = item.getBoundingClientRect();
28
+ const delta = row.top < top ? row.top - top : row.bottom > bottom ? Math.min(row.bottom - bottom, row.top - top) : 0;
29
+ return Math.max(0, Math.min(port.scrollHeight - port.clientHeight, port.scrollTop + delta / scale));
30
+ }
31
+
32
+ /** Resolve after rendering so HTML and framework adapters reveal the same active row. */
33
+ export function revealActiveItem(root, resolve) {
34
+ const view = root.ownerDocument.defaultView;
35
+ const item = typeof resolve === "function" ? resolve() : resolve;
36
+ if (item && pending.get(root)?.item === item) return;
37
+ cancelActiveItemReveal(root);
38
+ const current = { item, frame: 0 };
39
+ pending.set(root, current);
40
+ current.frame = view.requestAnimationFrame(() => {
41
+ current.item = typeof resolve === "function" ? resolve() : resolve;
42
+ if (!root.isConnected || !current.item?.getClientRects().length || !root.contains(current.item)) {
43
+ pending.delete(root);
44
+ return;
45
+ }
46
+ const port = scrollport(root, current.item, view);
47
+ if (!port) { pending.delete(root); return; }
48
+ const from = port.scrollTop;
49
+ const to = destination(port, current.item, view);
50
+ const token = view.getComputedStyle(root).getPropertyValue("--ui-motion-fast").trim();
51
+ const duration = view.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0
52
+ : (parseFloat(token) || 0) * (token.endsWith("ms") ? 1 : 1000);
53
+ if (Math.abs(to - from) < 1 || duration <= 0) {
54
+ port.scrollTop = to;
55
+ pending.delete(root);
56
+ return;
57
+ }
58
+ const started = view.performance.now();
59
+ const animate = (now) => {
60
+ if (!root.isConnected || !current.item.getClientRects().length) { pending.delete(root); return; }
61
+ const progress = Math.min(1, (now - started) / duration);
62
+ port.scrollTop = from + (to - from) * (1 - (1 - progress) ** 3);
63
+ if (progress < 1) current.frame = view.requestAnimationFrame(animate);
64
+ else pending.delete(root);
65
+ };
66
+ current.frame = view.requestAnimationFrame(animate);
67
+ });
68
+ }
69
+
70
+ export function focusAndRevealItem(root, item) {
71
+ if (!item) return;
72
+ item.focus({ preventScroll: true });
73
+ revealActiveItem(root, item);
74
+ }
@@ -1,3 +1,5 @@
1
+ import { focusAndRevealItem } from "./active-item-reveal.js";
2
+
1
3
  const itemSelector = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
2
4
  const searches = new WeakMap();
3
5
 
@@ -44,6 +46,6 @@ export function navigateMenu(event, menu) {
44
46
  }
45
47
  }
46
48
  event.preventDefault();
47
- if (next >= 0) items[next].focus();
49
+ if (next >= 0) focusAndRevealItem(menu, items[next]);
48
50
  return true;
49
51
  }
@@ -19,6 +19,9 @@
19
19
  >Height, padding, and type size. xs is 1.5rem with the smallest text, for a control inside a line of small text; sm is the small control height (2rem by
20
20
  default) with tighter padding and smaller text, md is the standard control height (2.5rem), and lg is taller (3rem) with roomier padding. Visible sizes
21
21
  stay the same on touch. Inline xs and link actions can gain an adjustable invisible hit area.</prop>
22
+ <prop name="square" type="boolean" default="false"
23
+ >Gives short labels and icons the same square minimum footprint at each size. Longer labels grow naturally. Applies to boxed actions; link and card variants
24
+ keep their normal layout.</prop>
22
25
  <prop name="shape" type="keyword" values="rounded, pill" default="rounded"
23
26
  >rounded uses the theme's action radius; pill rounds the ends fully, for a compact choice such as a status or a person.</prop>
24
27
  <prop name="tone" type="keyword" values="accent, neutral, danger, success, warning, info" default="accent"
@@ -233,6 +236,7 @@
233
236
  }
234
237
 
235
238
  :host {
239
+ --_button-square-size: var(--ui-button-min-block-size, var(--ui-control-size-md));
236
240
  appearance: none;
237
241
  min-block-size: var(--ui-button-min-block-size, var(--ui-control-size-md));
238
242
  border-style: solid;
@@ -297,6 +301,7 @@
297
301
  }
298
302
 
299
303
  :host([size="xs"]) {
304
+ --_button-square-size: 1.5rem;
300
305
  min-block-size: 1.5rem;
301
306
  padding: calc(var(--ui-space-0-5) * 1.5) calc(var(--ui-space-1) * 1.25) !important;
302
307
  font-size: var(--ui-font-size-xs);
@@ -307,17 +312,25 @@
307
312
  }
308
313
 
309
314
  :host([size="sm"]) {
315
+ --_button-square-size: var(--ui-control-size-sm);
310
316
  min-block-size: var(--ui-control-size-sm);
311
317
  padding: var(--ui-control-inset-block-sm, var(--ui-space-1)) var(--ui-action-inset-sm, var(--ui-space-2)) !important;
312
318
  font-size: var(--ui-font-size-sm);
313
319
  }
314
320
 
315
321
  :host([size="lg"]) {
322
+ --_button-square-size: var(--ui-control-size-lg);
316
323
  min-block-size: var(--ui-control-size-lg);
317
324
  padding: var(--ui-control-inset-block-lg, var(--ui-space-3)) var(--ui-action-inset-lg, var(--ui-space-4)) !important;
318
325
  font-size: var(--ui-font-size-md);
319
326
  }
320
327
 
328
+ /* Short numeric and icon actions share a minimum box; large values remain readable. */
329
+ :host([square]):not([data-appearance="link"], [data-appearance="card"]) {
330
+ min-inline-size: var(--_button-square-size);
331
+ padding-inline: var(--ui-space-1) !important;
332
+ }
333
+
321
334
  /* Card actions inherit Button's palette and behavior; only their shared layout changes.
322
335
  The icon may contain any decorative composition. Empty icons remove their column. */
323
336
  :host[data-appearance="card"] {
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, revealActiveItem } from "../shared/active-item-reveal.js";
1
2
  import { afterFormReset } from "../shared/form-reset.js";
2
3
  import { closeOverlay, createAnchoredSurface, openOverlay } from "../shared/overlay.js";
3
4
 
@@ -107,6 +108,7 @@ export function connect(host) {
107
108
  lookup = undefined;
108
109
  };
109
110
  const close = () => {
111
+ cancelActiveItemReveal(popup);
110
112
  host.state.expanded = false;
111
113
  host.state.active = -1;
112
114
  cancelLookup();
@@ -326,7 +328,7 @@ export function connect(host) {
326
328
  const current = indices.indexOf(host.state.active);
327
329
  const next = key === "Home" ? 0 : key === "End" ? indices.length - 1 : key === "ArrowDown" ? Math.min(current + 1, indices.length - 1) : current < 0 ? indices.length - 1 : Math.max(0, current - 1);
328
330
  host.state.active = indices[next];
329
- requestAnimationFrame(() => document.getElementById(`${uid}-option-${host.state.active}`)?.scrollIntoView({ block: "nearest" }));
331
+ revealActiveItem(popup, () => document.getElementById(`${uid}-option-${host.state.active}`));
330
332
  };
331
333
  const validateCurrent = async () => {
332
334
  validationRun?.abort();
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, focusAndRevealItem } from "../shared/active-item-reveal.js";
1
2
  import { closeOverlay, createAnchoredSurface, createIdResolver, openOverlay, requestTopOverlayClose } from "../shared/overlay.js";
2
3
  import { menuItemFrom, menuItems, navigateMenu } from "../shared/menu-navigation.js";
3
4
 
@@ -19,7 +20,7 @@ export function connect(host) {
19
20
  host.state.internalOpen = lastOpenProp;
20
21
 
21
22
  const items = () => menuItems(element);
22
- const focusFirst = () => requestAnimationFrame(() => items()[0]?.focus());
23
+ const focusFirst = () => requestAnimationFrame(() => focusAndRevealItem(element, items()[0]));
23
24
  const targetEvents = { contextmenu: onContextMenu, keydown: onTargetKeydown, pointerdown: onPointerdown, pointerup: endPress, pointercancel: endPress };
24
25
  const detach = () => {
25
26
  for (const [type, listener] of Object.entries(targetEvents)) target?.removeEventListener(type, listener);
@@ -37,6 +38,7 @@ export function connect(host) {
37
38
  // Light dismiss leaves focus where the user pointed instead of pulling it back to the target.
38
39
  const close = (reason, input, returnFocus = reason !== "light-dismiss") => {
39
40
  if (!host.state.internalOpen) return;
41
+ cancelActiveItemReveal(element);
40
42
  host.state.internalOpen = false;
41
43
  endPress();
42
44
  host.dispatch("close", { open: false, reason, trigger: input });
@@ -147,6 +149,7 @@ export function connect(host) {
147
149
  const stop = host.effect(apply);
148
150
  apply();
149
151
  return () => {
152
+ cancelActiveItemReveal(element);
150
153
  stop();
151
154
  ids.stop();
152
155
  observer.disconnect();
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, focusAndRevealItem } from "../shared/active-item-reveal.js";
1
2
  // Gives each form its own field ids, reports field changes and choices, saves on Enter in a field,
2
3
  // closes on Escape, and moves between the search field and its results with the arrow keys.
3
4
  export function connect(host) {
@@ -26,7 +27,7 @@ export function connect(host) {
26
27
  const moveFocus = (event, offset) => {
27
28
  const results = [...element.querySelectorAll("[data-link-result]")];
28
29
  const next = results[results.indexOf(event.target) + offset];
29
- if (next) next.focus();
30
+ if (next) focusAndRevealItem(element, next);
30
31
  else if (offset < 0) element.querySelector("[data-link-search]")?.focus();
31
32
  else return;
32
33
  event.preventDefault();
@@ -57,6 +58,7 @@ export function connect(host) {
57
58
  element.addEventListener("keydown", onKeyDown);
58
59
  element.addEventListener("submit", onSubmit);
59
60
  return () => {
61
+ cancelActiveItemReveal(element);
60
62
  element.removeEventListener("input", onInput);
61
63
  element.removeEventListener("change", onChange);
62
64
  element.removeEventListener("click", onClick);
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, revealActiveItem } from "../shared/active-item-reveal.js";
1
2
  import { createViewportSurface } from "../shared/overlay.js";
2
3
  import { normalizeAnchor, positionMenu } from "../shared/editor.js";
3
4
 
@@ -25,8 +26,13 @@ export function connect(host) {
25
26
  const visible = Boolean(host.props.open.value && normalizeAnchor(host.props.anchorRect.value)
26
27
  && (host.props.loading.value || rows.length > 0 || empty));
27
28
  if (host.state.visible !== visible) host.state.visible = visible;
28
- if (visible) surface.show();
29
- else surface.hide();
29
+ if (visible) {
30
+ surface.show();
31
+ revealActiveItem(element, rows[active]);
32
+ } else {
33
+ cancelActiveItemReveal(element);
34
+ surface.hide();
35
+ }
30
36
  };
31
37
  const stop = host.effect(() => {
32
38
  if (!authoredLabel) element.setAttribute("aria-label", String(host.props.label.value || "Mentions"));
@@ -43,7 +49,7 @@ export function connect(host) {
43
49
  const row = rowOf(event);
44
50
  if (row) host.dispatch("select", { index: Number(row.dataset.index), value: row.dataset.value ?? "" });
45
51
  };
46
- const onMouseover = (event) => {
52
+ const onMousemove = (event) => {
47
53
  const row = rowOf(event);
48
54
  if (!row) return;
49
55
  const index = Number(row.dataset.index);
@@ -56,13 +62,14 @@ export function connect(host) {
56
62
  observer.observe(element, { childList: true, subtree: true });
57
63
  element.addEventListener("mousedown", onMousedown);
58
64
  element.addEventListener("click", onClick);
59
- element.addEventListener("mouseover", onMouseover);
65
+ element.addEventListener("mousemove", onMousemove);
60
66
  return () => {
67
+ cancelActiveItemReveal(element);
61
68
  stop();
62
69
  surface.destroy();
63
70
  observer.disconnect();
64
71
  element.removeEventListener("mousedown", onMousedown);
65
72
  element.removeEventListener("click", onClick);
66
- element.removeEventListener("mouseover", onMouseover);
73
+ element.removeEventListener("mousemove", onMousemove);
67
74
  };
68
75
  }
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, revealActiveItem } from "../shared/active-item-reveal.js";
1
2
  import { createViewportSurface } from "../shared/overlay.js";
2
3
  import { normalizeAnchor, positionMenu } from "../shared/editor.js";
3
4
 
@@ -33,8 +34,13 @@ export function connect(host) {
33
34
  const visible = Boolean(host.props.open.value && normalizeAnchor(host.props.anchorRect.value)
34
35
  && (rows.length > 0 || host.refs.empty.children.length));
35
36
  if (host.state.visible !== visible) host.state.visible = visible;
36
- if (visible) surface.show();
37
- else surface.hide();
37
+ if (visible) {
38
+ surface.show();
39
+ revealActiveItem(element, rows[active]);
40
+ } else {
41
+ cancelActiveItemReveal(element);
42
+ surface.hide();
43
+ }
38
44
  };
39
45
  const stop = host.effect(() => {
40
46
  const items = Array.isArray(host.props.items.value) ? host.props.items.value : [];
@@ -49,7 +55,7 @@ export function connect(host) {
49
55
  const row = rowOf(event);
50
56
  if (row && !row.hidden && row.getAttribute("aria-disabled") !== "true") host.dispatch("select", { index: Number(row.dataset.index), value: row.dataset.value ?? "" });
51
57
  };
52
- const onMouseover = (event) => {
58
+ const onMousemove = (event) => {
53
59
  const row = rowOf(event);
54
60
  if (!row || row.hidden || row.getAttribute("aria-disabled") === "true") return;
55
61
  const index = Number(row.dataset.index);
@@ -62,13 +68,14 @@ export function connect(host) {
62
68
  observer.observe(element, { childList: true, subtree: true });
63
69
  element.addEventListener("mousedown", onMousedown);
64
70
  element.addEventListener("click", onClick);
65
- element.addEventListener("mouseover", onMouseover);
71
+ element.addEventListener("mousemove", onMousemove);
66
72
  return () => {
73
+ cancelActiveItemReveal(element);
67
74
  stop();
68
75
  surface.destroy();
69
76
  observer.disconnect();
70
77
  element.removeEventListener("mousedown", onMousedown);
71
78
  element.removeEventListener("click", onClick);
72
- element.removeEventListener("mouseover", onMouseover);
79
+ element.removeEventListener("mousemove", onMousemove);
73
80
  };
74
81
  }
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, revealActiveItem } from "../shared/active-item-reveal.js";
1
2
  let nextId = 0;
2
3
 
3
4
  const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
@@ -79,7 +80,7 @@ export function connect(host) {
79
80
  if (index < 0 || options[index]?.disabled || !options[index]) return;
80
81
  host.state.active = index;
81
82
  render();
82
- element.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" });
83
+ revealActiveItem(element, () => element.querySelector(`[data-index="${host.state.active}"]`));
83
84
  };
84
85
  const choose = (index, trigger) => {
85
86
  const option = options[index];
@@ -158,6 +159,7 @@ export function connect(host) {
158
159
  form?.addEventListener("reset", onReset);
159
160
  validity.addEventListener("invalid", onInvalid);
160
161
  return () => {
162
+ cancelActiveItemReveal(element);
161
163
  stop();
162
164
  observer.disconnect();
163
165
  authored.disabled = host.props.disabled.value;
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, focusAndRevealItem } from "../shared/active-item-reveal.js";
1
2
  import { closeOverlay, createAnchoredSurface, createIdResolver, openOverlay, requestTopOverlayClose } from "../shared/overlay.js";
2
3
  import { menuItemFrom, menuItems, navigateMenu } from "../shared/menu-navigation.js";
3
4
 
@@ -30,11 +31,12 @@ export function connect(host) {
30
31
  else {
31
32
  host.state.internalOpen = true;
32
33
  host.dispatch("open", { open: true, reason: "action", trigger });
33
- requestAnimationFrame(() => items()[0]?.focus());
34
+ requestAnimationFrame(() => focusAndRevealItem(element, items()[0]));
34
35
  }
35
36
  };
36
37
  const close = (reason, trigger) => {
37
38
  if (!host.state.internalOpen) return;
39
+ cancelActiveItemReveal(element);
38
40
  host.state.internalOpen = false;
39
41
  host.dispatch("close", { open: false, reason, trigger });
40
42
  if (reason === "escape") anchor?.focus();
@@ -102,6 +104,7 @@ export function connect(host) {
102
104
  host.dispatch("select", { value, trigger });
103
105
  if (host.props.inline.value) return;
104
106
  host.dispatch("close", { open: false, reason: "action", trigger });
107
+ cancelActiveItemReveal(element);
105
108
  host.state.internalOpen = false;
106
109
  };
107
110
  const onClick = (event) => select(menuItemFrom(event.target), triggerFor(event));
@@ -130,6 +133,7 @@ export function connect(host) {
130
133
  const stop = host.effect(apply);
131
134
  apply();
132
135
  return () => {
136
+ cancelActiveItemReveal(element);
133
137
  stop();
134
138
  ids.stop();
135
139
  element.removeEventListener("click", onClick);
@@ -0,0 +1,74 @@
1
+ <link rel="component" href="../ui-button/ui-button.html">
2
+ <link rel="component" href="../ui-icon/ui-icon.html">
3
+ <link rel="component" href="../ui-cluster/ui-cluster.html">
4
+ <template component="ui-pagination" module="./ui-pagination.js">
5
+ <defs>
6
+ <prop name="page" type="number" default="1"
7
+ >Current one-based page. The consumer updates it after a navigate request; activation never changes it internally.</prop>
8
+ <prop name="pages" type="list" default="[]">Visible numbered pages in ascending order. Omit for previous/next navigation without a known total. Gaps between
9
+ numbers show an ellipsis. Supply href to keep a numbered page usable as a native link before JavaScript.
10
+ <prop type="object"><prop name="value" type="number" required></prop><prop name="href" type="string"></prop></prop>
11
+ </prop>
12
+ <prop name="hasPrevious" type="boolean" default="false">Whether a preceding page is available. Previous is always unavailable on page one.</prop>
13
+ <prop name="hasNext" type="boolean" default="false">Whether another page is available. The component does not infer a total.</prop>
14
+ <prop name="previousHref" type="string" default="">Optional native destination for Previous. Empty renders a request button.</prop>
15
+ <prop name="nextHref" type="string" default="">Optional native destination for Next. Empty renders a request button.</prop>
16
+ <prop name="label" type="string" default="Results pages">Accessible name for the navigation landmark.</prop>
17
+ <prop name="previousLabel" type="string" default="Previous">Accessible label for the preceding page action.</prop>
18
+ <prop name="nextLabel" type="string" default="Next">Accessible label for the following page action.</prop>
19
+ <prop name="pageLabel" type="string" default="Page">Prefix for each numbered page's accessible name, followed by its localized number.</prop>
20
+ <prop name="size" type="keyword" values="sm, md, lg" default="sm"
21
+ >Uses Button's shared size scale. Narrow groups wrap without enlarging compact controls.</prop>
22
+ <prop name="disabled" type="boolean" default="false">Makes all page controls unavailable while keeping the current page visible.</prop>
23
+ <event name="navigate" type="object">
24
+ <prop name="page" type="number" required></prop><prop name="previousPage" type="number" required></prop><prop
25
+ name="trigger"
26
+ type="keyword"
27
+ values="keyboard, pointer, programmatic"
28
+ required
29
+ ></prop>
30
+ </event>
31
+ </defs>
32
+ <nav from:aria-label="$label">
33
+ <ui-cluster gap="xs">
34
+ <ui-button
35
+ square
36
+ variant="ghost"
37
+ tone="neutral"
38
+ from:size="$size"
39
+ from:disabled="$disabled or not $hasPrevious or $page &lt;= 1"
40
+ from:as="{ true: 'a', false: 'button' }[concat($previousHref != '')]"
41
+ from:href="$previousHref"
42
+ from:aria-label="$previousLabel"
43
+ from:data-pagination-page="$page - 1"
44
+ ><ui-icon name="chevron-left"></ui-icon></ui-button>
45
+ <template $each="item, index of $pages" $key="$item.value">
46
+ <span $if="$index > 0 and $item.value > $pages[$index - 1].value + 1" aria-hidden="true">&hellip;</span>
47
+ <ui-button
48
+ square
49
+ from:variant="{ true: 'solid', false: 'ghost' }[concat($item.value = $page)]"
50
+ from:tone="{ true: 'accent', false: 'neutral' }[concat($item.value = $page)]"
51
+ from:size="$size"
52
+ from:disabled="$disabled"
53
+ from:as="{ true: 'a', false: 'button' }[concat(default($item.href, '') != '')]"
54
+ from:href="$item.href"
55
+ from:data-pagination-page="$item.value"
56
+ from:aria-label="concat($pageLabel, ' ', format($item.value, 'number'))"
57
+ from:aria-current="{ true: 'page', false: null }[concat($item.value = $page)]"
58
+ >{format($item.value, 'number')}</ui-button>
59
+ </template>
60
+ <ui-button
61
+ square
62
+ variant="ghost"
63
+ tone="neutral"
64
+ from:size="$size"
65
+ from:disabled="$disabled or not $hasNext"
66
+ from:as="{ true: 'a', false: 'button' }[concat($nextHref != '')]"
67
+ from:href="$nextHref"
68
+ from:aria-label="$nextLabel"
69
+ from:data-pagination-page="$page + 1"
70
+ ><ui-icon name="chevron-right"></ui-icon></ui-button>
71
+ <slot></slot>
72
+ </ui-cluster>
73
+ </nav>
74
+ </template>
@@ -0,0 +1,19 @@
1
+ import { trackTrigger } from "../shared/trigger.js";
2
+
3
+ /** Report page intent while leaving controlled state and native navigation with the consumer. */
4
+ export function connect(host) {
5
+ const [takeTrigger, stopTrigger] = trackTrigger(host);
6
+ const activate = (event) => {
7
+ const control = event.target.closest?.("[data-pagination-page]");
8
+ if (!control || !host.element.contains(control)) return;
9
+ const trigger = takeTrigger();
10
+ if (event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
11
+ if (host.props.disabled.value || control.matches(":disabled, [aria-disabled='true']")) return;
12
+ const page = Number(control.getAttribute("data-pagination-page"));
13
+ const previousPage = host.props.page.value;
14
+ if (!Number.isSafeInteger(page) || page < 1 || page === previousPage) return;
15
+ host.dispatch("navigate", { page, previousPage, trigger });
16
+ };
17
+ host.element.addEventListener("click", activate);
18
+ return () => { stopTrigger(); host.element.removeEventListener("click", activate); };
19
+ }
@@ -1,3 +1,4 @@
1
+ import { cancelActiveItemReveal, focusAndRevealItem } from "../shared/active-item-reveal.js";
1
2
  import { closeDialog, closeOverlay, openOverlay, showDialog } from "../shared/overlay.js";
2
3
  import { trackTrigger } from "../shared/trigger.js";
3
4
 
@@ -19,6 +20,7 @@ export function connect(host) {
19
20
  };
20
21
  const closeNative = () => {
21
22
  suppressNativeClose = true;
23
+ cancelActiveItemReveal(dialog);
22
24
  closeDialog(dialog);
23
25
  suppressNativeClose = false;
24
26
  };
@@ -63,10 +65,10 @@ export function connect(host) {
63
65
  const index = rows.indexOf(target.closest?.(".body button[aria-current]"));
64
66
  if (!fromSearch && index < 0) return;
65
67
  event.preventDefault();
66
- if (event.key === "Home") rows[0].focus();
67
- else if (event.key === "End") rows.at(-1).focus();
68
- else if (event.key === "ArrowDown") rows[fromSearch ? 0 : (index + 1) % rows.length].focus();
69
- else rows[fromSearch ? rows.length - 1 : (index - 1 + rows.length) % rows.length].focus();
68
+ if (event.key === "Home") focusAndRevealItem(dialog, rows[0]);
69
+ else if (event.key === "End") focusAndRevealItem(dialog, rows.at(-1));
70
+ else if (event.key === "ArrowDown") focusAndRevealItem(dialog, rows[fromSearch ? 0 : (index + 1) % rows.length]);
71
+ else focusAndRevealItem(dialog, rows[fromSearch ? rows.length - 1 : (index - 1 + rows.length) % rows.length]);
70
72
  };
71
73
  const onClick = (event) => {
72
74
  if (host.props.dismissible.value && event.target === dialog) dispatchClose("light-dismiss", trigger());
@@ -83,6 +85,7 @@ export function connect(host) {
83
85
  dialog.addEventListener("click", onClick);
84
86
  dialog.addEventListener("close", onClose);
85
87
  return () => {
88
+ cancelActiveItemReveal(dialog);
86
89
  stop();
87
90
  stopTracking();
88
91
  dialog.removeEventListener("cancel", onCancel);