@ulu/frontend-vue 0.5.15 → 0.6.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 (75) hide show
  1. package/dist/components/elements/UluButton.vue.d.ts +4 -0
  2. package/dist/components/elements/UluButton.vue.d.ts.map +1 -1
  3. package/dist/components/elements/UluButton.vue.js +31 -16
  4. package/dist/components/elements/UluIcon.vue.js +21 -36
  5. package/dist/components/forms/UluFormCheckbox.vue.d.ts +3 -19
  6. package/dist/components/forms/UluFormCheckbox.vue.d.ts.map +1 -1
  7. package/dist/components/forms/UluFormCheckbox.vue.js +10 -31
  8. package/dist/components/forms/UluFormFile.vue.d.ts +3 -25
  9. package/dist/components/forms/UluFormFile.vue.d.ts.map +1 -1
  10. package/dist/components/forms/UluFormFile.vue.js +11 -49
  11. package/dist/components/forms/UluFormItem.vue.d.ts +23 -8
  12. package/dist/components/forms/UluFormItem.vue.d.ts.map +1 -1
  13. package/dist/components/forms/UluFormItem.vue.js +126 -29
  14. package/dist/components/forms/UluFormLabel.vue.d.ts +24 -0
  15. package/dist/components/forms/UluFormLabel.vue.d.ts.map +1 -0
  16. package/dist/components/forms/UluFormLabel.vue.js +34 -0
  17. package/dist/components/forms/UluFormRadio.vue.d.ts +7 -25
  18. package/dist/components/forms/UluFormRadio.vue.d.ts.map +1 -1
  19. package/dist/components/forms/UluFormRadio.vue.js +11 -37
  20. package/dist/components/forms/UluFormSelect.vue.d.ts +7 -23
  21. package/dist/components/forms/UluFormSelect.vue.d.ts.map +1 -1
  22. package/dist/components/forms/UluFormSelect.vue.js +24 -43
  23. package/dist/components/forms/UluFormText.vue.d.ts +5 -23
  24. package/dist/components/forms/UluFormText.vue.d.ts.map +1 -1
  25. package/dist/components/forms/UluFormText.vue.js +10 -38
  26. package/dist/components/forms/UluFormTextarea.vue.d.ts +5 -23
  27. package/dist/components/forms/UluFormTextarea.vue.d.ts.map +1 -1
  28. package/dist/components/forms/UluFormTextarea.vue.js +10 -37
  29. package/dist/components/forms/UluSearchForm.vue.d.ts +24 -3
  30. package/dist/components/forms/UluSearchForm.vue.d.ts.map +1 -1
  31. package/dist/components/forms/UluSearchForm.vue.js +67 -22
  32. package/dist/components/index.d.ts +1 -0
  33. package/dist/components/systems/facets/UluFacetsFilterSelects.vue.d.ts.map +1 -1
  34. package/dist/components/systems/facets/UluFacetsFilterSelects.vue.js +21 -22
  35. package/dist/components/systems/scroll-anchors/UluScrollAnchors.vue.d.ts.map +1 -1
  36. package/dist/components/systems/scroll-anchors/UluScrollAnchors.vue.js +18 -10
  37. package/dist/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue.d.ts +8 -2
  38. package/dist/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue.d.ts.map +1 -1
  39. package/dist/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue.js +92 -47
  40. package/dist/components/systems/scroll-anchors/useScrollAnchors.d.ts.map +1 -1
  41. package/dist/components/systems/scroll-anchors/useScrollAnchors.js +113 -70
  42. package/dist/components/utils/UluAction.vue.d.ts +2 -0
  43. package/dist/components/utils/UluAction.vue.d.ts.map +1 -1
  44. package/dist/components/utils/UluAction.vue.js +9 -5
  45. package/dist/components/visualizations/UluProgressBar.vue.d.ts +2 -2
  46. package/dist/index.js +130 -128
  47. package/dist/plugins/core/index.d.ts.map +1 -1
  48. package/dist/plugins/core/index.js +17 -16
  49. package/dist/utils/props.d.ts +7 -0
  50. package/dist/utils/props.d.ts.map +1 -1
  51. package/dist/utils/props.js +8 -2
  52. package/lib/components/elements/UluButton.vue +18 -3
  53. package/lib/components/elements/UluIcon.vue +8 -26
  54. package/lib/components/forms/UluForm.vue +25 -25
  55. package/lib/components/forms/UluFormCheckbox.vue +11 -25
  56. package/lib/components/forms/UluFormFieldset.vue +6 -6
  57. package/lib/components/forms/UluFormFile.vue +10 -40
  58. package/lib/components/forms/UluFormItem.vue +150 -39
  59. package/lib/components/forms/UluFormLabel.vue +30 -0
  60. package/lib/components/forms/UluFormRadio.vue +15 -34
  61. package/lib/components/forms/UluFormSelect.vue +19 -24
  62. package/lib/components/forms/UluFormText.vue +7 -25
  63. package/lib/components/forms/UluFormTextarea.vue +7 -25
  64. package/lib/components/forms/UluSearchForm.vue +67 -19
  65. package/lib/components/forms/UluSelectableMenu.vue +62 -62
  66. package/lib/components/index.js +1 -0
  67. package/lib/components/systems/facets/UluFacetsFilterSelects.vue +11 -14
  68. package/lib/components/systems/scroll-anchors/UluScrollAnchors.vue +9 -1
  69. package/lib/components/systems/scroll-anchors/UluScrollAnchorsNavAnimated.vue +95 -16
  70. package/lib/components/systems/scroll-anchors/_scroll-anchors-nav-animated.scss +12 -1
  71. package/lib/components/systems/scroll-anchors/useScrollAnchors.js +195 -54
  72. package/lib/components/utils/UluAction.vue +6 -2
  73. package/lib/plugins/core/index.js +2 -1
  74. package/lib/utils/props.js +14 -0
  75. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  import { onMounted, onUnmounted, nextTick, watch } from "vue";
2
2
  import { getScrollParent } from "@ulu/utils/browser/dom.js";
3
+ import { debounce } from "@ulu/utils/performance.js";
3
4
 
4
5
  /**
5
6
  * The main composable that contains the core "engine" for the Scroll Anchors system.
@@ -10,96 +11,236 @@ import { getScrollParent } from "@ulu/utils/browser/dom.js";
10
11
  */
11
12
  export function useScrollAnchors({ sections, props, emit, componentElRef }) {
12
13
  let observer = null;
14
+ let lastScrollY = 0;
15
+ let lastScrollDirection = 'down';
16
+ let scrollRoot = null;
17
+ let isObserverRefresh = true;
13
18
 
19
+ /**
20
+ * Helper to quickly get the index of a section by its DOM element
21
+ */
14
22
  function getSectionIndex(el) {
15
23
  return sections.value.findIndex(({ element }) => el === element);
16
24
  }
17
25
 
26
+ /**
27
+ * Standardizes state assignments (active, forward/reverse animations) for a specific section
28
+ */
29
+ function setSectionState(section, isActive, scrollDirection = 'down') {
30
+ if (!section) return;
31
+ const direction = scrollDirection === 'down' ? 'forward' : 'reverse';
32
+ if (isActive) {
33
+ section.active = true;
34
+ section.inactiveFrom = null;
35
+ section.activeFrom = direction;
36
+ } else {
37
+ if (section.active) {
38
+ section.inactiveFrom = direction;
39
+ section.activeFrom = null;
40
+ }
41
+ section.active = false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Loops through all sections and forces them to inactive, optionally skipping one target
47
+ */
18
48
  function removeActive(except = null, scrollDirection = 'down') {
19
49
  sections.value.forEach(s => {
20
50
  if (s !== except) {
21
- if (s.active) {
22
- s.inactiveFrom = scrollDirection === 'down' ? 'forward' : 'reverse';
23
- s.activeFrom = null;
24
- }
25
- s.active = false;
51
+ setSectionState(s, false, scrollDirection);
26
52
  }
27
53
  });
28
54
  }
29
55
 
56
+ /**
57
+ * Safe wrapper to clear other sections, make a new target active, and emit the change event
58
+ */
59
+ function setSectionActive(section, scrollDirection) {
60
+ if (section && !section.active) {
61
+ if (props.debug) console.log("Activate:", section.title);
62
+ nextTick(() => {
63
+ removeActive(section, scrollDirection);
64
+ setSectionState(section, true, scrollDirection);
65
+ emit("section-change", { section, sections: sections.value, active: true });
66
+ });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Clears the currently active item and emits a deactivation event (used at top/bottom scroll edges)
72
+ */
73
+ function deactivateAll(scrollDirection, debugMsg) {
74
+ const activeSection = sections.value.find(s => s.active);
75
+ if (activeSection) {
76
+ if (props.debug && debugMsg) console.log(debugMsg, activeSection.title);
77
+ nextTick(() => {
78
+ removeActive(null, scrollDirection);
79
+ emit("section-change", { section: activeSection, sections: sections.value, active: false });
80
+ });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolves the scrolling container element based on options, DOM hierarchy, or defaults to window
86
+ */
87
+ function getScrollRoot() {
88
+ let root = null;
89
+ if (props.observerOptions && props.observerOptions.root !== undefined) {
90
+ root = props.observerOptions.root;
91
+ } else if (componentElRef.value) {
92
+ root = getScrollParent(componentElRef.value);
93
+ if (root === document.scrollingElement) {
94
+ root = null;
95
+ }
96
+ }
97
+ return root || window;
98
+ }
99
+
100
+ /**
101
+ * Debounced callback attached to the scroll listener.
102
+ * Rebuilding the observer ensures a fresh set of entries representing the true layout when scrolling stops.
103
+ */
104
+ const refreshObserver = debounce(() => {
105
+ if (props.debug) console.log("New Observer (debounced/check)");
106
+ if (observer) {
107
+ observer.disconnect();
108
+ observer = null;
109
+ }
110
+ createObserver();
111
+ observeItems();
112
+ }, 100);
113
+
114
+ function setupScrollListener() {
115
+ removeScrollListener();
116
+ scrollRoot = getScrollRoot();
117
+ if (scrollRoot) {
118
+ scrollRoot.addEventListener('scroll', refreshObserver, { passive: true });
119
+ }
120
+ }
121
+
122
+ function removeScrollListener() {
123
+ if (scrollRoot) {
124
+ scrollRoot.removeEventListener('scroll', refreshObserver);
125
+ scrollRoot = null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Primary logic core. Creates the IntersectionObserver and handles state changes.
131
+ */
30
132
  function createObserver() {
31
- let lastScrollY = 0;
32
- let isInitialObservation = true;
133
+ isObserverRefresh = true;
33
134
 
34
135
  const onObserve = (entries) => {
35
136
  const { root } = observer;
36
137
  const currentScrollY = root ? root.scrollTop : document.documentElement.scrollTop || window.scrollY;
37
138
 
38
- if (props.debug) {
39
- console.group("useScrollAnchors: onObserve");
40
- console.log("Observer:", observer);
41
- console.log("Last/Current Y:", `${ lastScrollY }/${ currentScrollY }`);
42
- console.log("Entries:", entries.map(e => ({ el: e.target, is: e.isIntersecting })));
139
+ // Determine the direction of the scroll
140
+ let scrollDirection = lastScrollDirection;
141
+ if (currentScrollY > lastScrollY) {
142
+ scrollDirection = 'down';
143
+ } else if (currentScrollY < lastScrollY) {
144
+ scrollDirection = 'up';
43
145
  }
44
146
 
45
- if (isInitialObservation && props.firstItemActive) {
46
- if (props.debug) console.log("Initial observation, respecting `firstItemActive`.");
47
- isInitialObservation = false;
48
- lastScrollY = currentScrollY;
49
- if (props.debug) console.groupEnd();
50
- return;
147
+ if (props.debug) {
148
+ console.groupCollapsed(`Scroll: ${lastScrollY} -> ${currentScrollY} (${scrollDirection})`);
149
+ console.table(entries.map(e => ({
150
+ el: e.target.id || e.target.tagName,
151
+ int: e.isIntersecting,
152
+ ratio: e.intersectionRatio.toFixed(2)
153
+ })));
51
154
  }
52
- isInitialObservation = false;
53
155
 
54
- const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up';
55
156
  lastScrollY = currentScrollY;
56
- if (props.debug) console.log(`Scroll direction: ${scrollDirection}`);
157
+ lastScrollDirection = scrollDirection;
57
158
 
58
159
  const intersectingEntries = entries.filter(entry => entry.isIntersecting);
59
- if (props.debug) console.log("Intersecting entries:", intersectingEntries.map(e => e.target));
60
160
 
161
+ // Strategy 1: Standard intersection detection
61
162
  if (intersectingEntries.length > 0) {
62
163
  intersectingEntries.sort((a, b) => getSectionIndex(a.target) - getSectionIndex(b.target));
63
164
 
165
+ // Choose the most appropriate intersecting entry based on scroll direction
64
166
  const targetEntry = scrollDirection === 'down'
65
167
  ? intersectingEntries[intersectingEntries.length - 1]
66
168
  : intersectingEntries[0];
67
- if (props.debug) console.log("Chosen target entry:", targetEntry.target);
169
+
170
+ if (props.debug) console.log("Target:", targetEntry.target.id || targetEntry.target.tagName);
68
171
 
69
172
  const sectionToActivate = sections.value[getSectionIndex(targetEntry.target)];
70
-
71
- if (sectionToActivate && !sectionToActivate.active) {
72
- if (props.debug) console.log("Activating section:", sectionToActivate.title);
73
- nextTick(() => {
74
- removeActive(sectionToActivate, scrollDirection);
75
- sectionToActivate.active = true;
76
- sectionToActivate.inactiveFrom = null;
77
- sectionToActivate.activeFrom = scrollDirection === 'down' ? 'forward' : 'reverse';
78
- emit("section-change", { section: sectionToActivate, sections: sections.value, active: true });
79
- });
80
- }
173
+ setSectionActive(sectionToActivate, scrollDirection);
81
174
  } else {
82
- if (props.debug) console.log("No intersecting entries. Checking edge cases.");
83
- const activeSection = sections.value.find(s => s.active);
84
- if (activeSection) {
85
- const entryForActive = entries.find(e => e.target === activeSection.element);
86
- if (entryForActive && !entryForActive.isIntersecting) {
87
- const index = getSectionIndex(entryForActive.target);
88
- const isFirst = index === 0;
89
- const isLast = index === sections.value.length - 1;
90
- if ((isFirst && scrollDirection === 'up' && !props.firstItemActive) || (isLast && scrollDirection === 'down')) {
91
- if (props.debug) console.log("Deactivating section at edge:", activeSection.title);
92
- nextTick(() => {
93
- removeActive(null, scrollDirection);
94
- emit("section-change", { section: activeSection, sections: sections.value, active: false });
95
- });
175
+ // Strategy 2: Absolute positioning fallback (fired automatically on fresh observer load)
176
+ // Used to instantly catch sections we skipped entirely during warp-speed scrolling.
177
+ if (isObserverRefresh) {
178
+ if (props.debug) console.log("Fallback: bounds");
179
+
180
+ let highestAboveIndex = -1;
181
+ entries.forEach(entry => {
182
+ const rootTop = entry.rootBounds ? entry.rootBounds.top : 0;
183
+ // +1 buffer for fractional pixels, check if element is above the reading zone
184
+ if (entry.boundingClientRect.top <= rootTop + 1) {
185
+ const idx = getSectionIndex(entry.target);
186
+ if (idx > highestAboveIndex) {
187
+ highestAboveIndex = idx;
188
+ }
189
+ }
190
+ });
191
+
192
+ // Activate the last section that is above the reading zone viewport
193
+ if (highestAboveIndex > -1) {
194
+ const isLast = highestAboveIndex === sections.value.length - 1;
195
+ const targetSection = sections.value[highestAboveIndex];
196
+
197
+ // Edge case: User scrolled past the bottom of the very last section into a footer
198
+ if (isLast && props.deactivateLastItem) {
199
+ const lastEntry = entries.find(e => e.target === targetSection.element);
200
+ const rootBottom = lastEntry.rootBounds ? lastEntry.rootBounds.bottom : window.innerHeight;
201
+ if (lastEntry && lastEntry.boundingClientRect.bottom < rootBottom) {
202
+ deactivateAll(scrollDirection, "Deactivate (last):");
203
+ } else {
204
+ setSectionActive(targetSection, scrollDirection);
205
+ }
206
+ } else {
207
+ setSectionActive(targetSection, scrollDirection);
208
+ }
209
+ } else {
210
+ // No elements are above the reading zone viewport, we are above the first item
211
+ if (props.debug) console.log("Fallback: top");
212
+ if (!props.firstItemActive) {
213
+ deactivateAll(scrollDirection, "Deactivate (top):");
214
+ } else {
215
+ const firstSection = sections.value[0];
216
+ setSectionActive(firstSection, scrollDirection);
217
+ }
218
+ }
219
+ } else {
220
+ // Strategy 3: Real-time edge deactivation
221
+ // Fires as user slowly scrolls out of the bounds of the first or last items
222
+ if (props.debug) console.log("Check edges");
223
+ const activeSection = sections.value.find(s => s.active);
224
+ if (activeSection) {
225
+ const entryForActive = entries.find(e => e.target === activeSection.element);
226
+ if (entryForActive && !entryForActive.isIntersecting) {
227
+ const index = getSectionIndex(entryForActive.target);
228
+ const isFirst = index === 0;
229
+ const isLast = index === sections.value.length - 1;
230
+ if ((isFirst && scrollDirection === 'up' && !props.firstItemActive) || (isLast && scrollDirection === 'down' && props.deactivateLastItem)) {
231
+ deactivateAll(scrollDirection, "Deactivate (edge):");
232
+ }
96
233
  }
97
234
  }
98
235
  }
99
236
  }
237
+
238
+ // Observer refresh cycle completed
239
+ isObserverRefresh = false;
100
240
  if (props.debug) console.groupEnd();
101
241
  };
102
242
 
243
+ // Calculate options and margins for the reading zone
103
244
  let root = null;
104
245
  if (props.observerOptions && props.observerOptions.root !== undefined) {
105
246
  root = props.observerOptions.root;
@@ -147,32 +288,32 @@ export function useScrollAnchors({ sections, props, emit, componentElRef }) {
147
288
  }
148
289
 
149
290
  onMounted(() => {
150
- if (props.firstItemActive && sections.value.length > 0) {
151
- const first = sections.value[0];
152
- if (first) {
153
- first.active = true;
154
- }
155
- }
156
291
  createObserver();
157
292
  observeItems();
293
+ setupScrollListener();
158
294
  });
159
295
 
160
296
  onUnmounted(() => {
161
297
  destroyObserver();
298
+ removeScrollListener();
299
+ refreshObserver.cancel();
162
300
  });
163
301
 
302
+ // Re-observe items dynamically if the section list grows/shrinks
164
303
  watch(() => sections.value.length, () => {
165
304
  nextTick(() => {
166
305
  observeItems();
167
306
  });
168
307
  });
169
308
 
309
+ // Hot swap the observer if the developer changes options or snap settings
170
310
  watch(
171
311
  () => [props.snapOffset, props.observerOptions],
172
312
  () => {
173
313
  destroyObserver();
174
314
  createObserver();
175
315
  observeItems();
316
+ setupScrollListener();
176
317
  },
177
318
  { deep: true }
178
319
  );
@@ -48,7 +48,11 @@
48
48
  * Allows passing 'click' as a prop to signify this is an action (used in UluMenu data objects).
49
49
  * Note: The actual @click listener should be attached via fallthrough attrs, this is just for logic routing.
50
50
  */
51
- click: Function
51
+ click: Function,
52
+ /**
53
+ * Button type (e.g. 'submit', 'reset', 'button'). Defaults to 'button' to prevent accidental form submissions.
54
+ */
55
+ type: String
52
56
  });
53
57
 
54
58
  const resolvedElement = computed(() => {
@@ -73,7 +77,7 @@
73
77
  }
74
78
  } else if (!props.element || props.element === "button") {
75
79
  // It's a button, ensure it doesn't accidentally submit forms unless requested
76
- attrs.type = "button";
80
+ attrs.type = props.type || "button";
77
81
  }
78
82
 
79
83
  return attrs;
@@ -27,7 +27,8 @@ const defaults = {
27
27
  file: "fas fa-file",
28
28
  previous: "fas fa-chevron-left",
29
29
  next: "fas fa-chevron-right",
30
- dropdownExpand: "fas fa-caret-down"
30
+ dropdownExpand: "fas fa-caret-down",
31
+ search: "fas fa-search"
31
32
  }
32
33
  };
33
34
 
@@ -5,4 +5,18 @@
5
5
  */
6
6
  export function isArrayOfObjects(array) {
7
7
  return array.every(item => typeof item === "object");
8
+ }
9
+
10
+ /**
11
+ * Checks for deprecated props and calls a callback for each match
12
+ * @param {object} props - The current props object
13
+ * @param {string[]} deprecatedNames - Array of prop names to check
14
+ * @param {function} callback - Function called for each match, receiving the prop name
15
+ */
16
+ export function checkDeprecatedProps(props, deprecatedNames, callback) {
17
+ deprecatedNames.forEach(name => {
18
+ if (props[name] !== undefined) {
19
+ callback(name);
20
+ }
21
+ });
8
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulu/frontend-vue",
3
- "version": "0.5.15",
3
+ "version": "0.6.0",
4
4
  "description": "A modular, tree-shakeable Vue 3 component library for the Ulu Frontend theming system, plus general utilities for Vue development",
5
5
  "type": "module",
6
6
  "files": [
@@ -65,7 +65,7 @@
65
65
  "@fortawesome/vue-fontawesome": "^3.0.8",
66
66
  "@headlessui/vue": "^1.7.23",
67
67
  "@portabletext/vue": "^1.0.14",
68
- "@ulu/frontend": "^0.5.0",
68
+ "@ulu/frontend": "^0.6.8",
69
69
  "@ulu/utils": "^0.0.34",
70
70
  "@unhead/vue": "^2.0.11",
71
71
  "fuse.js": "^6.6.2",
@@ -87,7 +87,7 @@
87
87
  "@storybook/addon-essentials": "^9.0.0-alpha.12",
88
88
  "@storybook/addon-links": "^9.1.1",
89
89
  "@storybook/vue3-vite": "^9.1.1",
90
- "@ulu/frontend": "^0.4.11",
90
+ "@ulu/frontend": "^0.6.8",
91
91
  "@ulu/utils": "^0.0.34",
92
92
  "@unhead/vue": "^2.0.11",
93
93
  "@vitejs/plugin-vue": "^6.0.0",