@yuuvis/client-framework 2.6.3 → 2.8.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 (45) hide show
  1. package/common/lib/components/busy-overlay/busy-overlay.component.d.ts +0 -1
  2. package/datepicker/lib/calendar/calendar.component.d.ts +1 -2
  3. package/datepicker/lib/date-input/date-input.component.d.ts +1 -1
  4. package/fesm2022/yuuvis-client-framework-common.mjs +1 -4
  5. package/fesm2022/yuuvis-client-framework-common.mjs.map +1 -1
  6. package/fesm2022/yuuvis-client-framework-datepicker.mjs +32 -29
  7. package/fesm2022/yuuvis-client-framework-datepicker.mjs.map +1 -1
  8. package/fesm2022/yuuvis-client-framework-forms.mjs +7 -4
  9. package/fesm2022/yuuvis-client-framework-forms.mjs.map +1 -1
  10. package/fesm2022/yuuvis-client-framework-list.mjs +96 -62
  11. package/fesm2022/yuuvis-client-framework-list.mjs.map +1 -1
  12. package/fesm2022/yuuvis-client-framework-object-flavor.mjs +2 -2
  13. package/fesm2022/yuuvis-client-framework-object-flavor.mjs.map +1 -1
  14. package/fesm2022/yuuvis-client-framework-object-versions.mjs +1 -1
  15. package/fesm2022/yuuvis-client-framework-object-versions.mjs.map +1 -1
  16. package/fesm2022/yuuvis-client-framework-query-list.mjs +284 -0
  17. package/fesm2022/yuuvis-client-framework-query-list.mjs.map +1 -0
  18. package/fesm2022/yuuvis-client-framework-tile-list.mjs +92 -203
  19. package/fesm2022/yuuvis-client-framework-tile-list.mjs.map +1 -1
  20. package/fesm2022/yuuvis-client-framework-tree.mjs +8 -5
  21. package/fesm2022/yuuvis-client-framework-tree.mjs.map +1 -1
  22. package/fesm2022/yuuvis-client-framework.mjs +716 -160
  23. package/fesm2022/yuuvis-client-framework.mjs.map +1 -1
  24. package/lib/config/halo-focus-defaults/halo-excluded-elements.const.d.ts +26 -0
  25. package/lib/config/halo-focus-defaults/halo-focus-navigation-keys.const.d.ts +25 -0
  26. package/lib/config/halo-focus-defaults/halo-focus-offset.const.d.ts +25 -0
  27. package/lib/config/halo-focus-defaults/halo-focus-styles.const.d.ts +26 -0
  28. package/lib/config/halo-focus-defaults/index.d.ts +4 -0
  29. package/lib/config/index.d.ts +1 -0
  30. package/lib/models/halo-focus-config/halo-focus-config.model.d.ts +48 -0
  31. package/lib/models/halo-focus-config/index.d.ts +1 -0
  32. package/lib/models/index.d.ts +1 -0
  33. package/lib/providers/halo-focus/index.d.ts +1 -0
  34. package/lib/providers/halo-focus/provide-halo-focus.d.ts +58 -6
  35. package/lib/providers/index.d.ts +1 -1
  36. package/lib/services/halo-focus/halo-focus.service.d.ts +80 -17
  37. package/lib/services/halo-utility/halo-utility.service.d.ts +230 -0
  38. package/lib/services/index.d.ts +1 -0
  39. package/list/lib/list.component.d.ts +35 -6
  40. package/package.json +19 -15
  41. package/query-list/README.md +3 -0
  42. package/query-list/index.d.ts +2 -0
  43. package/query-list/lib/query-list.component.d.ts +152 -0
  44. package/query-list/lib/query-list.module.d.ts +7 -0
  45. package/tile-list/lib/tile-list/tile-list.component.d.ts +60 -34
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { NgModule, Injectable, inject, signal, Component, provideAppInitializer, NgZone } from '@angular/core';
2
+ import { NgModule, inject, Injectable, signal, Component, makeEnvironmentProviders, provideAppInitializer, NgZone } from '@angular/core';
3
3
  import { CommonModule } from '@angular/common';
4
4
  import { MatSnackBar, MatSnackBarRef, MAT_SNACK_BAR_DATA, MatSnackBarLabel, MatSnackBarActions, MatSnackBarAction } from '@angular/material/snack-bar';
5
5
  import * as i1 from '@angular/material/button';
@@ -18,60 +18,234 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.15", ngImpo
18
18
  }] });
19
19
 
20
20
  /**
21
- * The purpose of this service is to create and manage a "halo focus" element that visually
22
- * highlights the currently focused element on the page, enhancing keyboard navigation visibility.
23
- * It listens for focus and blur events, and when an element receives focus, it positions
24
- * and styles the halo element around it. The halo updates its position and size in response
25
- * to window scrolls, resizes, and changes to the focused element itself.
21
+ * List of element selectors that should be excluded from halo focus
22
+ * when they are inside a Material form field component (mat-form-field).
26
23
  *
27
- * This service is initialized once and operates globally, ensuring that any focusable
28
- * element on the page can be highlighted when focused.
24
+ * These elements typically have their own built-in focus indicators provided
25
+ * by Angular Material, making the halo focus redundant or visually conflicting.
29
26
  *
30
- * Usage:
31
- * 1. Add provideHaloFocus() to your providers array in your app.config.ts
32
- * 2. The service will automatically append a halo element to the DOM and manage its visibility
27
+ * **Included Selectors:**
28
+ * - `input` - Text inputs, number inputs, etc. inside mat-form-field
29
+ * - `textarea` - Multi-line text inputs inside mat-form-field
30
+ * - `mat-select` - Material select dropdowns inside mat-form-field
33
31
  *
34
- * Note: This service assumes that the application uses focus-visible styles to differentiate
35
- * between keyboard and mouse focus. It only shows the halo for elements that match :focus-visible.
32
+ * **Why Exclude These:**
33
+ * Angular Material form fields provide their own focus styling with floating labels,
34
+ * underlines, and color changes. Adding a halo around these elements would:
35
+ * - Create visual clutter and redundancy
36
+ * - Potentially conflict with Material's focus styling
37
+ * - Reduce the clarity of Material Design's established patterns
38
+ *
39
+ * **Note:** These elements will still receive halo focus if they appear OUTSIDE
40
+ * of mat-form-field containers. The exclusion only applies when they're wrapped
41
+ * in a mat-form-field.
42
+ *
43
+ * @see {@link HaloUtilityService.shouldSkipElementInMatFormField} for implementation
44
+ */
45
+ const haloExcludedElementsInMatFormField = ['input', 'textarea', 'mat-select'];
46
+
47
+ /**
48
+ * List of keyboard navigation keys that trigger halo position updates.
49
+ *
50
+ * When any of these keys are pressed while an element is focused, the halo service
51
+ * schedules an immediate position update to ensure the halo follows the focused element
52
+ * if it moves within the page (e.g., scrolling a list, moving through a table).
53
+ *
54
+ * **Included Keys:**
55
+ * - `ArrowLeft`, `ArrowRight`, `ArrowUp`, `ArrowDown` - Directional navigation
56
+ * - `Home`, `End` - Jump to start/end of content
57
+ * - `PageUp`, `PageDown` - Page-level scrolling
58
+ *
59
+ * **Why This Matters:**
60
+ * Arrow keys and navigation keys can cause the focused element to scroll or move within
61
+ * its container (e.g., scrolling a select dropdown, moving focus in a grid). By tracking
62
+ * these keys, the halo can immediately update its position to follow the element, preventing
63
+ * visual misalignment.
64
+ *
65
+ * **Performance Note:**
66
+ * Updates are scheduled using requestAnimationFrame, so even rapid key presses won't
67
+ * cause performance issues.
68
+ *
69
+ * @see {@link HaloFocusService} for the implementation of keydown handling
70
+ */
71
+ const haloFocusNavigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'];
72
+
73
+ /**
74
+ * Default offset (in pixels) between the halo border and the focused element's edges.
75
+ *
76
+ * This value determines how far the halo appears from the focused element:
77
+ * - **Positive value** (default: 3): Halo appears outside the element
78
+ * - **Zero (0)**: Halo aligns exactly with element edges (neutral offset)
79
+ * - **Negative value**: Halo appears inside the element (inset)
80
+ *
81
+ * **Override Methods:**
82
+ * - **Per-element**: Use `halo-offset="value"` attribute
83
+ * - **Per-container**: Use `halo-container-offset="value"` or preset attributes
84
+ *
85
+ * @example
86
+ * ```html
87
+ * <!-- Uses default 3px offset (outside) -->
88
+ * <button>Default</button>
89
+ *
90
+ * <!-- Custom 5px offset -->
91
+ * <input halo-offset="5" />
92
+ *
93
+ * ```
94
+ *
95
+ * @default 3
96
+ */
97
+ const defaultHaloFocusOffset = 3;
98
+
99
+ /**
100
+ * Default CSS styles for the halo focus element.
101
+ *
102
+ * These styles define the visual appearance and behavior of the halo border that
103
+ * highlights keyboard-focused elements. The halo is a fixed-position overlay that
104
+ * follows the focused element across the page.
105
+ *
106
+ * **Style Properties:**
107
+ * - `position: 'fixed'` - Keeps halo in viewport regardless of scroll position
108
+ * - `pointerEvents: 'none'` - Allows click-through; halo doesn't block interactions
109
+ * - `zIndex: '9999'` - Ensures halo appears above most other elements
110
+ * - `border` - Primary border using CSS variable `--ymt-primary` (2px solid)
111
+ * - `boxShadow` - Soft shadow with 20% opacity for subtle depth effect
112
+ * - `borderRadius` - Default 8px rounded corners for modern appearance
113
+ * - `opacity: '0'` - Initially hidden; visibility controlled by service
114
+ * - `transform: 'translateZ(0)'` - Creates GPU layer for smoother animations
115
+ *
116
+ * **CSS Variable Dependencies:**
117
+ * - `--ymt-primary` - Primary theme color for border and shadow
118
+ *
119
+ * **Note:** These are base styles that can be overridden globally via HaloFocusConfig
120
+ * or per-element using halo-* attributes.
121
+ *
122
+ * @see {@link HaloFocusConfig} for global style customization
123
+ */
124
+ const haloFocusStyles = {
125
+ position: 'fixed',
126
+ pointerEvents: 'none',
127
+ zIndex: '9999',
128
+ border: '2px solid var(--ymt-primary)',
129
+ boxShadow: '0 0 0 4px rgba(from var(--ymt-primary) r g b / 0.2)',
130
+ borderRadius: '8px',
131
+ opacity: '0',
132
+ transform: 'translateZ(0)'
133
+ };
134
+
135
+ /**
136
+ * Service that creates and manages a visual "halo" border around keyboard-focused elements
137
+ * to enhance navigation visibility and improve accessibility.
138
+ *
139
+ * This service operates globally across the application, automatically tracking focus events
140
+ * and dynamically positioning a halo element around the currently focused element. The halo
141
+ * responds to window scrolls, resizes, and DOM mutations to maintain accurate positioning.
142
+ *
143
+ * **Key Features:**
144
+ * - Keyboard-only focus indication (respects :focus-visible)
145
+ * - Performance-optimized with requestAnimationFrame and runs outside Angular zone
146
+ * - Element-level and container-level customization support
147
+ * - Automatic visibility detection (handles clipping, hidden elements, etc.)
148
+ * - Responsive to dynamic DOM changes via ResizeObserver and MutationObserver
149
+ * - Smart exclusion for Angular Material form fields (prevents redundant styling)
150
+ *
151
+ * **Usage:**
152
+ * 1. Add `provideHaloFocus()` to your providers array in app.config.ts
153
+ * 2. The service initializes automatically and appends a halo element to the DOM
154
+ * 3. Optionally customize appearance globally or per-element using attributes
155
+ *
156
+ * **Element-Level Attributes:**
157
+ * - `halo-skip` - Excludes element from halo highlighting
158
+ * - `halo-color="color"` - Custom halo color (e.g., "blue", "#00ff00", "rgb(255,0,0)")
159
+ * - `halo-offset="value"` - Custom numeric offset in pixels (default: 3)
160
+ *
161
+ * **Container-Level Attributes:**
162
+ * Define default halo styles for all focused children. Child elements can override
163
+ * container settings with their own halo-* attributes.
164
+ *
165
+ * - `halo-container` - Marks element as a halo container (required for other attributes)
166
+ * - `halo-container-skip` - Excludes all focused children from halo highlighting
167
+ * - `halo-container-color="color"` - Default halo color for focused children
168
+ * - `halo-container-offset="value"` - Default offset for focused children
169
+ *
170
+ * **Material Form Field Integration:**
171
+ * Elements inside Angular Material form fields (mat-form-field) are automatically excluded
172
+ * from halo focus to prevent visual conflicts with Material's built-in focus styling.
173
+ * Excluded elements: input, mat-select (configurable via haloExcludedElementsInMatFormField)
174
+ *
175
+ * @example
176
+ * ```html
177
+ * <!-- Element-level customization -->
178
+ * <button halo-color="red">Custom Red Halo</button>
179
+ * <input halo-offset="5" />
180
+ * <select halo-skip>No Halo</select>
181
+ *
182
+ * <!-- Container-level defaults -->
183
+ * <form halo-container halo-container-color="blue" halo-container-offset="5">
184
+ * <input /> <!-- Inherits blue color and 5px offset -->
185
+ * <button halo-color="red">Submit</button> <!-- Overrides with red, keeps 5px offset -->
186
+ * </form>
187
+ * ```
188
+ *
189
+ * **Performance Notes:**
190
+ * - Service runs outside Angular's zone to prevent unnecessary change detection
191
+ * - Uses requestAnimationFrame for smooth rendering
192
+ * - Only shows halo for keyboard interactions (respects :focus-visible)
193
+ *
194
+ * @see {@link provideHaloFocus} for initialization and configuration
195
+ * @see {@link HaloFocusConfig} for global configuration options
36
196
  */
37
197
  class HaloFocusService {
38
- #haloEl = null;
39
- #currentEl = null;
198
+ //#region Properties
199
+ #utility = inject(HaloUtilityService);
200
+ #config;
201
+ #haloElement = null;
202
+ #currentElement = null;
40
203
  #lastInteractionWasKeyboard = false;
41
204
  #rafId = null; //Request Animation Frame ID
42
205
  #resizeObserver;
43
206
  #mutationObserver;
44
- #defaultPadding = 3;
45
- #noPaddingComponents = ['yuv-shell-logo'];
207
+ //#endregion
208
+ //#region Init
46
209
  /**
47
- * Initializes the HaloFocusService by appending the halo element to the DOM
48
- * and setting up necessary event listeners and observers.
210
+ * Initializes the Halo Focus service and sets up the global halo tracking system.
211
+ *
212
+ * This method performs the following initialization steps:
213
+ * 1. Applies optional configuration for halo appearance (inner and outer colors)
214
+ * 2. Creates and appends the halo DIV element to the document body
215
+ * 3. Attaches global event listeners for focus, blur, keyboard, mouse, scroll, and resize events
216
+ * 4. Sets up ResizeObserver and MutationObserver for tracking dynamic DOM changes
217
+ *
218
+ * **Lifecycle:**
219
+ * - Called automatically by `provideHaloFocus()` during app initialization
220
+ * - Runs outside Angular's zone to prevent triggering change detection
221
+ * - Initializes only once per application lifecycle
49
222
  *
50
- * This method should be called once during the application startup.
223
+ * **Performance:**
224
+ * - Uses passive event listeners where applicable
225
+ * - Leverages requestAnimationFrame for efficient rendering
226
+ * - Observers are conditionally created only if browser supports them
51
227
  *
52
- * @returns void
228
+ * @param config - Optional configuration object for customizing halo appearance
229
+ * @param config.innerColor - Custom inner color (overrides default primary color)
230
+ * @param config.outerColor - Custom outer color (overrides default shadow)
231
+ *
232
+ * @internal This method should not be called manually; use `provideHaloFocus()` instead
53
233
  */
54
- init() {
234
+ init(config) {
235
+ this.#initConfig(config);
55
236
  this.#appendHaloElementToDOM();
56
237
  this.#initListeners();
57
238
  this.#initObservers();
58
239
  }
240
+ #initConfig(config) {
241
+ this.#config = config;
242
+ }
59
243
  #appendHaloElementToDOM() {
60
- this.#haloEl = document.createElement('div');
61
- this.#haloEl.id = 'focus-halo';
62
- this.#haloEl.setAttribute('aria-hidden', 'true');
63
- Object.assign(this.#haloEl.style, {
64
- position: 'fixed',
65
- pointerEvents: 'none',
66
- zIndex: '9999',
67
- border: '2px solid var(--ymt-primary)',
68
- boxShadow: '0 0 0 4px rgba(from var(--ymt-primary) r g b / 0.2)',
69
- borderRadius: '8px',
70
- opacity: '0',
71
- transform: 'translateZ(0)'
72
- // transition: 'opacity 120ms ease, transform 120ms ease, width 120ms ease, height 120ms ease, left 120ms ease, top 120ms ease'
73
- });
74
- document.body.appendChild(this.#haloEl);
244
+ this.#haloElement = document.createElement('div');
245
+ this.#haloElement.id = 'focus-halo';
246
+ this.#haloElement.setAttribute('aria-hidden', 'true');
247
+ Object.assign(this.#haloElement.style, this.#utility.getHaloFocusStyles(this.#config));
248
+ document.body.appendChild(this.#haloElement);
75
249
  }
76
250
  #initListeners() {
77
251
  document.addEventListener('focus', this.#onFocus, true);
@@ -84,132 +258,41 @@ class HaloFocusService {
84
258
  #initObservers() {
85
259
  if ('ResizeObserver' in window) {
86
260
  this.#resizeObserver = new ResizeObserver(() => {
87
- if (this.#currentEl)
261
+ if (this.#currentElement)
88
262
  this.#scheduleUpdate();
89
263
  });
90
264
  }
91
265
  if ('MutationObserver' in window) {
92
266
  this.#mutationObserver = new MutationObserver(() => {
93
- if (this.#currentEl)
267
+ if (this.#currentElement)
94
268
  this.#scheduleUpdate();
95
269
  });
96
270
  }
97
271
  }
98
- #isFocusable(el) {
99
- if (!el)
100
- return false;
101
- const h = el;
102
- if (h.tabIndex >= 0)
103
- return true;
104
- return /^(A|BUTTON|INPUT|SELECT|TEXTAREA)$/.test(h.tagName) || h.isContentEditable;
105
- }
106
- #matchesFocusVisible(el) {
107
- try {
108
- const matches = el.matches(':focus-visible');
109
- if (!matches)
110
- return false;
111
- return matches && this.#lastInteractionWasKeyboard;
112
- }
113
- catch {
114
- return this.#lastInteractionWasKeyboard;
115
- }
116
- }
117
- #isElementVisible(el) {
118
- const rect = el.getBoundingClientRect();
119
- // Check if element has dimensions
120
- if (rect.width === 0 || rect.height === 0) {
121
- return false;
122
- }
123
- // Check computed style on element itself
124
- const style = getComputedStyle(el);
125
- if (style.visibility === 'hidden' || style.display === 'none' || parseFloat(style.opacity) === 0) {
126
- return false;
127
- }
128
- // Check if element is clipped by overflow:hidden parent OR if any parent is hidden
129
- return this.#isParentVisible(el, rect);
130
- }
131
- #isParentVisible(el, rect) {
132
- let parent = el.parentElement;
133
- while (parent && parent !== document.body) {
134
- const parentStyle = getComputedStyle(parent);
135
- // Check if parent is hidden
136
- if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden' || parseFloat(parentStyle.opacity) === 0) {
137
- return false;
138
- }
139
- const parentOverflow = parentStyle.overflow + parentStyle.overflowX + parentStyle.overflowY;
140
- if (parentOverflow.includes('hidden') || parentOverflow.includes('clip')) {
141
- const parentRect = parent.getBoundingClientRect();
142
- // Calculate intersection - if there's no visible area, element is hidden
143
- const visibleRight = Math.min(rect.right, parentRect.right);
144
- const visibleLeft = Math.max(rect.left, parentRect.left);
145
- const visibleBottom = Math.min(rect.bottom, parentRect.bottom);
146
- const visibleTop = Math.max(rect.top, parentRect.top);
147
- const visibleWidth = visibleRight - visibleLeft;
148
- const visibleHeight = visibleBottom - visibleTop;
149
- // If no intersection or intersection is too small (less than 1px), element is not visible
150
- if (visibleWidth <= 0 || visibleHeight <= 0) {
151
- return false;
152
- }
153
- }
154
- parent = parent.parentElement;
155
- }
156
- return true;
157
- }
272
+ // #endregion
273
+ //#region Rendering
158
274
  #updateHaloRect = () => {
159
- if (!this.#currentEl || !this.#haloEl || !document.contains(this.#currentEl)) {
160
- this.#haloEl && (this.#haloEl.style.opacity = '0');
161
- this.#currentEl = null;
275
+ if (!this.#currentElement || !this.#haloElement || !document.contains(this.#currentElement)) {
276
+ this.#haloElement && (this.#haloElement.style.opacity = '0');
277
+ this.#currentElement = null;
162
278
  return;
163
279
  }
164
280
  // Check if element is actually visible
165
- if (!this.#isElementVisible(this.#currentEl)) {
166
- this.#haloEl.style.opacity = '0';
281
+ if (!this.#utility.isElementVisible(this.#currentElement)) {
282
+ this.#haloElement.style.opacity = '0';
167
283
  return;
168
284
  }
169
- const rect = this.#currentEl.getBoundingClientRect();
170
- const padding = this.#getTargetPadding();
171
- const left = Math.floor(rect.left - padding);
172
- const top = Math.floor(rect.top - padding);
173
- const width = Math.ceil(rect.width + padding * 2);
174
- const height = Math.ceil(rect.height + padding * 2);
175
- const cs = getComputedStyle(this.#currentEl);
176
- this.#haloEl.style.left = `${left}px`;
177
- this.#haloEl.style.top = `${top}px`;
178
- this.#haloEl.style.width = `${width}px`;
179
- this.#haloEl.style.height = `${height}px`;
180
- this.#haloEl.style.borderRadius = cs.borderRadius || '8px';
181
- this.#haloEl.style.opacity = '1';
285
+ this.#utility.setHaloElementSize(this.#currentElement, this.#haloElement);
286
+ this.#utility.setCustomColor(this.#currentElement, this.#haloElement, this.#config);
287
+ this.#haloElement.style.opacity = '1';
182
288
  };
183
- #getTargetPadding() {
184
- const parent = this.#currentEl?.parentElement;
185
- if (parent && this.#noPaddingComponents.includes(parent.nodeName.toLowerCase())) {
186
- return 0;
187
- }
188
- return this.#defaultPadding;
189
- }
190
289
  #scheduleUpdate = () => {
191
290
  if (this.#rafId != null)
192
291
  cancelAnimationFrame(this.#rafId);
193
292
  this.#rafId = requestAnimationFrame(this.#updateHaloRect);
194
293
  };
195
- #onKeydown = (e) => {
196
- this.#lastInteractionWasKeyboard = true;
197
- // Track arrow keys and other navigation keys that might move focused element
198
- const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'];
199
- if (this.#currentEl && navigationKeys.includes(e.key)) {
200
- this.#scheduleUpdate();
201
- }
202
- };
203
- #onMouseDown = () => {
204
- this.#lastInteractionWasKeyboard = false;
205
- if (this.#haloEl) {
206
- this.#haloEl.style.opacity = '0';
207
- this.#currentEl = null;
208
- this.#stopTracking();
209
- }
210
- };
211
294
  #startTracking(target) {
212
- this.#currentEl = target;
295
+ this.#currentElement = target;
213
296
  this.#scheduleUpdate();
214
297
  // Track size changes on target element
215
298
  if (this.#resizeObserver) {
@@ -240,22 +323,44 @@ class HaloFocusService {
240
323
  if (this.#mutationObserver)
241
324
  this.#mutationObserver.disconnect();
242
325
  }
326
+ //#endregion
327
+ //#region Event handlers
328
+ #onKeydown = (e) => {
329
+ this.#lastInteractionWasKeyboard = true;
330
+ // Track arrow keys and other navigation keys that might move focused element
331
+ if (this.#currentElement && haloFocusNavigationKeys.includes(e.key)) {
332
+ this.#scheduleUpdate();
333
+ }
334
+ };
335
+ #onMouseDown = () => {
336
+ this.#lastInteractionWasKeyboard = false;
337
+ if (this.#haloElement) {
338
+ this.#haloElement.style.opacity = '0';
339
+ this.#currentElement = null;
340
+ this.#stopTracking();
341
+ }
342
+ };
243
343
  #onFocus = (e) => {
244
344
  const target = e.target;
245
- if (!this.#isFocusable(target))
345
+ if (!this.#utility.isFocusable(target))
246
346
  return;
247
- if (!this.#matchesFocusVisible(target)) {
248
- this.#haloEl && (this.#haloEl.style.opacity = '0');
249
- this.#currentEl = null;
347
+ const container = this.#utility.getHaloContainer(target);
348
+ // Check all skip conditions
349
+ if (target.hasAttribute('halo-skip') ||
350
+ this.#utility.shouldSkipElementInMatFormField(target) ||
351
+ (container && container.hasAttribute('halo-container-skip')) ||
352
+ !this.#utility.matchesFocusVisible(target, this.#lastInteractionWasKeyboard)) {
353
+ this.#haloElement && (this.#haloElement.style.opacity = '0');
354
+ this.#currentElement = null;
250
355
  return;
251
356
  }
252
357
  this.#stopTracking();
253
358
  this.#startTracking(target);
254
359
  };
255
360
  #onBlur = (e) => {
256
- if (e.target === this.#currentEl && this.#haloEl) {
257
- this.#haloEl.style.opacity = '0';
258
- this.#currentEl = null;
361
+ if (e.target === this.#currentElement && this.#haloElement) {
362
+ this.#haloElement.style.opacity = '0';
363
+ this.#currentElement = null;
259
364
  this.#stopTracking();
260
365
  }
261
366
  };
@@ -353,24 +458,475 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.15", ngImpo
353
458
  }] });
354
459
 
355
460
  /**
356
- * Provides and initializes the HaloFocusService.
357
- * By calling this function in your ApplicationConfig providers,
358
- * the Halo Focus feature will be activated, allowing users to see a visual focus indicator.
359
- * The service will be initialized outside Angular zone for optimal performance.
461
+ * Utility service providing helper methods for the Halo Focus feature.
462
+ *
463
+ * This service contains pure utility functions for:
464
+ * - Element validation (focusability, visibility, focus-visible state)
465
+ * - Material form field detection and exclusion logic
466
+ * - Style calculations (colors, offsets, positioning)
467
+ * - DOM traversal (finding halo containers, checking parent visibility)
468
+ * - Halo element manipulation (size, color, position)
469
+ *
470
+ * **Scope & Performance:**
471
+ * This service is intentionally NOT provided in root (`providedIn: 'root'` is omitted).
472
+ * Instead, it's provided locally via `provideHaloFocus()`, ensuring it's only instantiated
473
+ * when the Halo Focus feature is actually used. This optimization prevents unnecessary
474
+ * service creation in applications that don't use halo focus.
475
+ *
476
+ * **Note:** This service is used exclusively by `HaloFocusService` and should not be
477
+ * injected or used elsewhere in the application.
478
+ *
479
+ * @internal
480
+ */
481
+ class HaloUtilityService {
482
+ //#region Validations
483
+ /**
484
+ * Checks if an element is focusable (can receive keyboard focus).
485
+ *
486
+ * An element is considered focusable if:
487
+ * - It has a non-negative tabIndex (>= 0), OR
488
+ * - It's a natively focusable element (A, BUTTON, INPUT, SELECT, TEXTAREA), OR
489
+ * - It has contentEditable enabled
490
+ *
491
+ * @param element - Element to check
492
+ * @returns true if element can receive focus, false otherwise
493
+ */
494
+ isFocusable(element) {
495
+ if (!element)
496
+ return false;
497
+ const h = element;
498
+ if (h.tabIndex >= 0)
499
+ return true;
500
+ return /^(A|BUTTON|INPUT|SELECT|TEXTAREA)$/.test(h.tagName) || h.isContentEditable;
501
+ }
502
+ /**
503
+ * Checks if an element is inside a Material form field (mat-form-field).
504
+ *
505
+ * Traverses up the DOM tree from the element to check if any parent
506
+ * has the `mat-form-field` tag name. This is used to determine if
507
+ * certain elements should skip halo focus because they're already
508
+ * styled by Angular Material.
509
+ *
510
+ * **Search Scope:**
511
+ * - Starts from element itself
512
+ * - Stops at document.body
513
+ * - Returns true on first mat-form-field match
514
+ *
515
+ * @param element - The element to check
516
+ * @returns true if element is inside a mat-form-field, false otherwise
517
+ */
518
+ isElementInsideMatFormField(element) {
519
+ let current = element;
520
+ while (current && current !== document.body) {
521
+ if (current.tagName.toLowerCase() === 'mat-form-field') {
522
+ return true;
523
+ }
524
+ current = current.parentElement;
525
+ }
526
+ return false;
527
+ }
528
+ /**
529
+ * Determines if an element should be excluded from halo focus when inside mat-form-field.
530
+ *
531
+ * Checks two conditions:
532
+ * 1. Element tag name matches one of the excluded selectors (input, mat-select, etc.)
533
+ * 2. Element is inside a mat-form-field component
534
+ *
535
+ * If both conditions are true, the element should skip halo focus because
536
+ * Angular Material already provides adequate focus styling.
537
+ *
538
+ * **Excluded Elements (configurable via haloExcludedElementsInMatFormField):**
539
+ * - `input` elements inside mat-form-field
540
+ * - `textarea` elements inside mat-form-field
541
+ * - `mat-select` elements inside mat-form-field
542
+ *
543
+ * **Note:** Elements outside mat-form-field will NOT be skipped, even if
544
+ * their tag matches the exclusion list.
545
+ *
546
+ * @param element - The focused element to check
547
+ * @returns true if element should be skipped (no halo), false if halo should be shown
548
+ */
549
+ shouldSkipElementInMatFormField(element) {
550
+ const tagName = element.tagName.toLowerCase();
551
+ const isExcludedElement = haloExcludedElementsInMatFormField.includes(tagName);
552
+ if (!isExcludedElement) {
553
+ return false;
554
+ }
555
+ return this.isElementInsideMatFormField(element);
556
+ }
557
+ /**
558
+ * Determines if an element should display the halo based on :focus-visible state.
559
+ *
560
+ * This method respects the CSS :focus-visible pseudo-class, which indicates that
561
+ * focus was triggered by keyboard navigation (not mouse clicks). The halo only appears
562
+ * when both conditions are met:
563
+ * 1. Element matches :focus-visible (browser determines this)
564
+ * 2. Last user interaction was via keyboard (tracked by service)
565
+ *
566
+ * **Fallback:** If browser doesn't support :focus-visible, falls back to checking
567
+ * lastInteractionWasKeyboard only.
568
+ *
569
+ * @param element - The currently focused element
570
+ * @param lastInteractionWasKeyboard - Whether the last interaction was a keyboard event
571
+ * @returns true if halo should be visible, false otherwise
572
+ */
573
+ matchesFocusVisible(element, lastInteractionWasKeyboard) {
574
+ try {
575
+ const matches = element.matches(':focus-visible');
576
+ if (!matches)
577
+ return false;
578
+ return matches && lastInteractionWasKeyboard;
579
+ }
580
+ catch {
581
+ return lastInteractionWasKeyboard;
582
+ }
583
+ }
584
+ /**
585
+ * Checks if an element is actually visible on the page.
586
+ *
587
+ * Performs comprehensive visibility checks including:
588
+ * - Element has non-zero dimensions (width and height > 0)
589
+ * - Element is not hidden via CSS (display: none, visibility: hidden, opacity: 0)
590
+ * - Element is not clipped by parent containers with overflow: hidden/clip
591
+ * - All parent elements in the hierarchy are visible
592
+ *
593
+ * This prevents the halo from appearing around technically focused but visually
594
+ * hidden elements (e.g., elements in collapsed sections, off-screen elements).
595
+ *
596
+ * @param element - Element to check for visibility
597
+ * @returns true if element is visible to the user, false otherwise
598
+ */
599
+ isElementVisible(element) {
600
+ const rect = element.getBoundingClientRect();
601
+ // Check if element has dimensions
602
+ if (rect.width === 0 || rect.height === 0) {
603
+ return false;
604
+ }
605
+ // Check computed style on element itself
606
+ const style = getComputedStyle(element);
607
+ if (style.visibility === 'hidden' || style.display === 'none' || parseFloat(style.opacity) === 0) {
608
+ return false;
609
+ }
610
+ // Check if element is clipped by overflow:hidden parent OR if any parent is hidden
611
+ return this.isParentVisible(element, rect);
612
+ }
613
+ /**
614
+ * Recursively checks if all parent elements are visible and not clipping the target element.
615
+ *
616
+ * Traverses up the DOM tree from the element to document.body, checking each parent for:
617
+ * - Hidden state (display: none, visibility: hidden, opacity: 0)
618
+ * - Overflow clipping (overflow: hidden/clip) that completely hides the element
619
+ *
620
+ * If any parent has overflow clipping, calculates the visible intersection area.
621
+ * Returns false if element is completely clipped (no visible area).
622
+ *
623
+ * @param element - The element whose parents should be checked
624
+ * @param rect - The bounding rectangle of the element
625
+ * @returns true if element has visible area within all parent containers, false otherwise
626
+ */
627
+ isParentVisible(element, rect) {
628
+ let parent = element.parentElement;
629
+ while (parent && parent !== document.body) {
630
+ const parentStyle = getComputedStyle(parent);
631
+ // Check if parent is hidden
632
+ if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden' || parseFloat(parentStyle.opacity) === 0) {
633
+ return false;
634
+ }
635
+ const parentOverflow = parentStyle.overflow + parentStyle.overflowX + parentStyle.overflowY;
636
+ if (parentOverflow.includes('hidden') || parentOverflow.includes('clip')) {
637
+ const parentRect = parent.getBoundingClientRect();
638
+ // Calculate intersection - if there's no visible area, element is hidden
639
+ const visibleRight = Math.min(rect.right, parentRect.right);
640
+ const visibleLeft = Math.max(rect.left, parentRect.left);
641
+ const visibleBottom = Math.min(rect.bottom, parentRect.bottom);
642
+ const visibleTop = Math.max(rect.top, parentRect.top);
643
+ const visibleWidth = visibleRight - visibleLeft;
644
+ const visibleHeight = visibleBottom - visibleTop;
645
+ // If no intersection or intersection is too small (less than 1px), element is not visible
646
+ if (visibleWidth <= 0 || visibleHeight <= 0) {
647
+ return false;
648
+ }
649
+ }
650
+ parent = parent.parentElement;
651
+ }
652
+ return true;
653
+ }
654
+ //#endregion
655
+ // #region Utilities
656
+ /**
657
+ * Generates the complete CSS styles for the halo element, merging defaults with custom config.
658
+ *
659
+ * Takes the base styles from `haloFocusStyles` constant and overrides specific properties
660
+ * if custom configuration is provided. This allows global style customization while
661
+ * maintaining all required base styles.
662
+ *
663
+ * **Configurable Properties:**
664
+ * - Inner color (via config.innerColor)
665
+ * - Outer color (via config.outerColor)
666
+ *
667
+ * **Non-configurable Properties:**
668
+ * - position, pointerEvents, zIndex, opacity, transform (always use defaults)
669
+ *
670
+ * @param config - Optional global configuration object
671
+ * @returns Complete CSS style declaration object for the halo element
672
+ */
673
+ getHaloFocusStyles(config) {
674
+ return {
675
+ ...haloFocusStyles,
676
+ border: config?.innerColor ? `2px solid ${config.innerColor}` : haloFocusStyles.border,
677
+ boxShadow: config?.outerColor ? `0 0 0 4px ${config.outerColor}` : haloFocusStyles.boxShadow
678
+ };
679
+ }
680
+ /**
681
+ * Finds the nearest parent element marked as a halo container.
682
+ *
683
+ * Traverses up the DOM tree looking for an element with the `halo-container` attribute.
684
+ * Container elements can define default halo styles (color, offset) that apply to all
685
+ * their focused children, unless overridden by child-specific halo-* attributes.
686
+ *
687
+ * **Search Scope:**
688
+ * - Starts from element's immediate parent
689
+ * - Stops at document.body (does not check body itself)
690
+ * - Returns first matching ancestor
691
+ *
692
+ * @param element - The focused element to search from
693
+ * @returns The nearest halo container element, or null if none found
694
+ */
695
+ getHaloContainer(element) {
696
+ let parent = element.parentElement;
697
+ while (parent && parent !== document.body) {
698
+ if (parent.hasAttribute('halo-container')) {
699
+ return parent;
700
+ }
701
+ parent = parent.parentElement;
702
+ }
703
+ return null;
704
+ }
705
+ /**
706
+ * Converts any CSS color format to rgba with specified alpha transparency.
707
+ *
708
+ * Handles multiple color formats:
709
+ * - **rgba**: Replaces existing alpha value
710
+ * - **rgb**: Converts to rgba by appending alpha
711
+ * - **Hex** (#RRGGBB): Converts to rgba format
712
+ * - **Named colors**: Uses modern CSS `color-mix` function with alpha
713
+ *
714
+ * Used internally to create outer colors with reduced opacity from inner colors.
715
+ *
716
+ * @param color - CSS color in any valid format (rgba, rgb, hex, named)
717
+ * @param alpha - Alpha transparency value (0-1)
718
+ * @returns Color in rgba format with specified alpha
719
+ */
720
+ getColorWithAlpha(color, alpha) {
721
+ // If already rgba, try to replace alpha
722
+ if (color.startsWith('rgba')) {
723
+ return color.replace(/[\d.]+\)$/, `${alpha})`);
724
+ }
725
+ // If rgb, convert to rgba
726
+ if (color.startsWith('rgb')) {
727
+ return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
728
+ }
729
+ // If hex color, convert to rgba
730
+ if (color.startsWith('#')) {
731
+ const hex = color.replace('#', '');
732
+ const r = parseInt(hex.substring(0, 2), 16);
733
+ const g = parseInt(hex.substring(2, 4), 16);
734
+ const b = parseInt(hex.substring(4, 6), 16);
735
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
736
+ }
737
+ // For named colors or other formats, wrap in rgba with color-mix (modern approach)
738
+ // Fallback to appending alpha as hex if browser doesn't support color-mix
739
+ return `rgba(from ${color} r g b / ${alpha})`;
740
+ }
741
+ /**
742
+ * Calculates the offset (distance) between the halo border and element edges.
743
+ *
744
+ * Determines offset using the following priority order:
745
+ * 1. **Element's own attributes** (highest priority):
746
+ * - `halo-offset="value"` - Custom numeric value
747
+ * 2. **Parent container attributes** (if element has no offset):
748
+ * - `halo-container-offset="value"`
749
+ * 3. **Default offset** (if nothing specified): 3px
750
+ *
751
+ * @param element - The focused element to calculate offset for
752
+ * @returns Offset value in pixels
753
+ */
754
+ getTargetOffset(element) {
755
+ if (!element)
756
+ return defaultHaloFocusOffset;
757
+ // 1. Check element's own attributes first
758
+ const customOffset = element.getAttribute('halo-offset');
759
+ if (customOffset !== null) {
760
+ const parsed = parseFloat(customOffset);
761
+ if (!isNaN(parsed))
762
+ return parsed;
763
+ }
764
+ // 2. Check container attributes as fallback
765
+ const container = this.getHaloContainer(element);
766
+ if (container) {
767
+ const containerOffset = container.getAttribute('halo-container-offset');
768
+ if (containerOffset !== null) {
769
+ const parsed = parseFloat(containerOffset);
770
+ if (!isNaN(parsed))
771
+ return parsed;
772
+ }
773
+ }
774
+ // 3. Default: positive offset (halo outside element)
775
+ return defaultHaloFocusOffset;
776
+ }
777
+ /**
778
+ * Applies custom color styling to the halo element based on element or container attributes.
779
+ *
780
+ * Color resolution priority:
781
+ * 1. **Element's own color**: `halo-color="color"` attribute (highest priority)
782
+ * 2. **Container's color**: `halo-container-color="color"` from nearest halo container
783
+ * 3. **Default color**: From global config or base styles (if no custom color found)
784
+ *
785
+ * When custom color is found:
786
+ * - Sets inner color (2px solid border) with the custom color
787
+ * - Sets outer color (shadow) to match, with 20% alpha transparency for subtle depth
788
+ *
789
+ * When no custom color:
790
+ * - Resets to default styles from config or haloFocusStyles constant
791
+ *
792
+ * @param element - The focused element (checked for halo-color attribute)
793
+ * @param haloElement - The halo DIV element to apply styles to
794
+ * @param config - Optional global config for default colors
795
+ */
796
+ setCustomColor(element, haloElement, config) {
797
+ // 1. Check element's own color first
798
+ let customColor = element.getAttribute('halo-color');
799
+ // 2. If not found, check container color
800
+ if (!customColor) {
801
+ const container = this.getHaloContainer(element);
802
+ if (container) {
803
+ customColor = container.getAttribute('halo-container-color');
804
+ }
805
+ }
806
+ // Apply color or reset to default
807
+ if (customColor) {
808
+ haloElement.style.border = `2px solid ${customColor}`;
809
+ haloElement.style.boxShadow = `0 0 0 4px ${this.getColorWithAlpha(customColor, 0.2)}`;
810
+ }
811
+ else {
812
+ // Reset to default styles
813
+ haloElement.style.border = this.getHaloFocusStyles(config).border;
814
+ haloElement.style.boxShadow = this.getHaloFocusStyles(config).boxShadow;
815
+ }
816
+ }
817
+ /**
818
+ * Positions and sizes the halo element to surround the focused element.
819
+ *
820
+ * Calculates halo dimensions and position based on:
821
+ * - Element's bounding rectangle (viewport-relative position)
822
+ * - Calculated offset from getTargetOffset() (custom or default)
823
+ * - Element's border-radius for matching rounded corners
824
+ *
825
+ * **Calculation Details:**
826
+ * - Position: Element's top/left minus offset (for outside positioning)
827
+ * - Size: Element's width/height plus 2× offset (to extend on all sides)
828
+ * - Border radius: Inherited from focused element's computed style
829
+ *
830
+ * Uses Math.floor for position and Math.ceil for dimensions to prevent
831
+ * subpixel rendering issues and ensure full element coverage.
832
+ *
833
+ * @param currentElement - The currently focused element to surround
834
+ * @param haloElement - The halo DIV element to position and size
835
+ */
836
+ setHaloElementSize(currentElement, haloElement) {
837
+ const rect = currentElement.getBoundingClientRect();
838
+ const offset = this.getTargetOffset(currentElement);
839
+ const left = Math.floor(rect.left - offset);
840
+ const top = Math.floor(rect.top - offset);
841
+ const width = Math.ceil(rect.width + offset * 2);
842
+ const height = Math.ceil(rect.height + offset * 2);
843
+ const cs = getComputedStyle(currentElement);
844
+ haloElement.style.left = `${left}px`;
845
+ haloElement.style.top = `${top}px`;
846
+ haloElement.style.width = `${width}px`;
847
+ haloElement.style.height = `${height}px`;
848
+ haloElement.style.borderRadius = cs.borderRadius || '8px';
849
+ }
850
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.15", ngImport: i0, type: HaloUtilityService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
851
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.15", ngImport: i0, type: HaloUtilityService }); }
852
+ }
853
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.15", ngImport: i0, type: HaloUtilityService, decorators: [{
854
+ type: Injectable
855
+ }] });
856
+
857
+ /**
858
+ * Provides and initializes the Halo Focus feature for the Angular application.
859
+ *
860
+ * This function sets up a global visual accessibility enhancement that creates a
861
+ * "halo" border around keyboard-focused elements, making navigation more visible
862
+ * and improving the user experience for keyboard users.
863
+ *
864
+ * **What It Does:**
865
+ * - Registers HaloFocusService and HaloUtility as application-wide providers
866
+ * - Automatically initializes the service during app startup via APP_INITIALIZER
867
+ * - Runs initialization outside Angular zone for optimal performance
868
+ * - Creates a persistent halo element that tracks focus throughout the application
869
+ *
870
+ * **Key Features:**
871
+ * - Keyboard-only focus indication (respects CSS :focus-visible)
872
+ * - Performance-optimized with requestAnimationFrame and zone-free execution
873
+ * - Fully customizable via global config or per-element attributes
874
+ * - Supports container-level defaults for consistent styling
875
+ * - Automatic visibility detection and responsive to DOM changes
876
+ *
877
+ * **Configuration Options:**
878
+ * @param config - Optional global configuration object
879
+ * @param config.innerColor - Inner color of the halo (default: 'var(--ymt-primary)')
880
+ * @param config.outerColor - Outer color around the halo (default: 'rgba(from var(--ymt-primary) r g b / 0.2)')
881
+ *
882
+ * @returns EnvironmentProviders for the Halo Focus feature
883
+ *
884
+ * @example
885
+ * // Basic usage in app.config.ts
886
+ * export const appConfig: ApplicationConfig = {
887
+ * providers: [
888
+ * provideHaloFocus()
889
+ * ]
890
+ * };
891
+ *
892
+ * @example
893
+ * // With custom configuration
894
+ * export const appConfig: ApplicationConfig = {
895
+ * providers: [
896
+ * provideHaloFocus({
897
+ * innerColor: '#007bff',
898
+ * outerColor: 'rgba(0, 123, 255, 0.25)'
899
+ * })
900
+ * ]
901
+ * };
902
+ *
903
+ * @example
904
+ * // Using element-level attributes in your template
905
+ * <button halo-color="red">Custom Red Halo</button>
906
+ * <input halo-offset="5" />
907
+ * <select halo-skip>No Halo</select>
360
908
  *
361
- * @returns EnvironmentProviders
909
+ * @example
910
+ * // Using container-level attributes
911
+ * <form halo-container halo-container-color="blue" halo-container-offset="5">
912
+ * <input /> <!-- Will use blue color and 5px offset -->
913
+ * <button halo-color="red">Submit</button> <!-- Overrides with red -->
914
+ * </form>
362
915
  */
363
- function provideHaloFocus() {
364
- return provideAppInitializer(() => {
365
- const zone = inject(NgZone);
366
- const haloFocus = inject(HaloFocusService);
367
- zone.runOutsideAngular(() => haloFocus.init());
368
- });
916
+ function provideHaloFocus(config) {
917
+ return makeEnvironmentProviders([
918
+ HaloUtilityService,
919
+ provideAppInitializer(() => {
920
+ const zone = inject(NgZone);
921
+ const haloFocus = inject(HaloFocusService);
922
+ zone.runOutsideAngular(() => haloFocus.init(config));
923
+ })
924
+ ]);
369
925
  }
370
926
 
371
927
  /**
372
928
  * Generated bundle index. Do not edit.
373
929
  */
374
930
 
375
- export { HaloFocusService, SnackBarComponent, SnackBarService, YuuvisClientFrameworkModule, provideHaloFocus };
931
+ export { HaloFocusService, HaloUtilityService, SnackBarComponent, SnackBarService, YuuvisClientFrameworkModule, provideHaloFocus };
376
932
  //# sourceMappingURL=yuuvis-client-framework.mjs.map