@modernman00/shared-js-lib 1.2.81 → 1.2.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ import { setButtonLoading, clearButtonLoading } from '../ButtonLoader.js';
2
+
3
+ /**
4
+ * Universal Admin Auth Form Helper for @modernman00/shared-js-lib
5
+ * Handles non-blocking submit button states with spinner animation
6
+ * without interrupting browser event packaging.
7
+ */
8
+ export const initAdminAuthForm = (formSelector = 'form') => {
9
+ if (typeof document === 'undefined') return;
10
+
11
+ const form = document.querySelector(formSelector);
12
+ if (!form) return;
13
+
14
+ form.addEventListener('submit', (evt) => {
15
+ const submitBtn = form.querySelector('button[type="submit"]');
16
+ if (!submitBtn) return;
17
+
18
+ // Use non-blocking delay so browser packages POST payload before disabling
19
+ const loadingText = submitBtn.dataset.loadingText || 'Processing...';
20
+ setTimeout(() => {
21
+ setButtonLoading(submitBtn, loadingText);
22
+ }, 50);
23
+ });
24
+ };
25
+
26
+ export default initAdminAuthForm;
@@ -1,7 +1,10 @@
1
1
  // createFormHandler.js
2
+ 'use strict';
2
3
  import { bindEvent } from '../Utility.js';
3
4
  import { recaptchaToken } from './recaptchaToken.js';
4
5
  import { loginSubmission } from './processAll.js';
6
+ import { swalError } from '../Alert.js';
7
+ import { clearButtonLoading } from '../ButtonLoader.js';
5
8
 
6
9
  export function createFormHandler({
7
10
  formId,
@@ -16,20 +19,29 @@ export function createFormHandler({
16
19
  const handler = async (e) => {
17
20
  e.preventDefault();
18
21
 
19
- const token = await recaptchaToken(recaptchaAction);
22
+ try {
23
+ const token = await recaptchaToken(recaptchaAction);
20
24
 
21
- await loginSubmission({
22
- formId,
23
- loginURL: route,
24
- redirect,
25
- css: theme,
26
- lengthLimit,
27
- optionalFields,
28
- recaptchaToken: token
29
- ? { token, action: recaptchaAction }
30
- : null
31
- });
25
+ await loginSubmission({
26
+ formId,
27
+ loginURL: route,
28
+ redirect,
29
+ buttonId,
30
+ css: theme,
31
+ lengthLimit,
32
+ optionalFields,
33
+ recaptchaToken: token
34
+ ? { token, action: recaptchaAction }
35
+ : null
36
+ });
37
+ } catch (err) {
38
+ console.error('createFormHandler Error:', err);
39
+ swalError(err.message || 'Form submission encountered an unexpected error.', true);
40
+ clearButtonLoading(buttonId);
41
+ }
32
42
  };
33
- // 🔹 This is the click binding you’re referring to
43
+
44
+ // 🔹 Click binding with defensive execution
34
45
  bindEvent({ id: buttonId, handler });
35
46
  }
47
+
@@ -5,6 +5,8 @@ import { showLoader, clearLoader } from '../Loader.js';
5
5
  import { id } from '../UtilityHtml.js';
6
6
  import { showError } from '../ShowResponse.js';
7
7
  import FormHelper from '../FormHelper.js';
8
+ import { setButtonLoading, clearButtonLoading } from '../ButtonLoader.js';
9
+ import { swalError } from '../Alert.js';
8
10
 
9
11
  /**
10
12
  * Handles the submission of a form and redirects the user.
@@ -13,6 +15,7 @@ export const loginSubmission = async ({
13
15
  formId,
14
16
  loginURL,
15
17
  redirect,
18
+ buttonId = 'button',
16
19
  css = null,
17
20
  lengthLimit = null, // { id: [...], max: [...] } or null
18
21
  optionalFields = [],
@@ -23,6 +26,9 @@ export const loginSubmission = async ({
23
26
  loaderClasses = "animate-spin mx-auto block w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full";
24
27
  }
25
28
 
29
+ // Resolve target button (by provided buttonId, or finding submit button within form)
30
+ let targetButton = id(buttonId);
31
+
26
32
  try {
27
33
  const formInput = id(formId);
28
34
 
@@ -30,6 +36,10 @@ export const loginSubmission = async ({
30
36
  throw new Error(`Form ${formId} not found`);
31
37
  }
32
38
 
39
+ if (!targetButton) {
40
+ targetButton = formInput.querySelector('button[type="submit"], input[type="submit"], button');
41
+ }
42
+
33
43
  const formInputArr = Array.from(formInput.elements);
34
44
  const formData = new FormHelper(formInputArr);
35
45
 
@@ -49,9 +59,15 @@ export const loginSubmission = async ({
49
59
  return;
50
60
  }
51
61
 
52
- const notificationElement = id(`${formId}_notification`) || formInput;
62
+ const notificationElement = id(`${formId}_notification`) || formInput;
53
63
  notificationElement.scrollIntoView({ behavior: 'smooth' });
54
64
 
65
+ // Enable button spinner on the submit button itself
66
+ if (targetButton) {
67
+ setButtonLoading(targetButton);
68
+ }
69
+
70
+ // Keep external loader for backward compatibility
55
71
  showLoader('setLoader', loaderClasses);
56
72
  localStorage.setItem('redirect', redirect);
57
73
 
@@ -59,7 +75,13 @@ export const loginSubmission = async ({
59
75
 
60
76
  } catch (err) {
61
77
  showError(err);
78
+ swalError(err.message || 'An error occurred while submitting the form.', true);
62
79
  } finally {
80
+ // Guaranteed cleanup: remove button spinner & external loader
81
+ if (targetButton) {
82
+ clearButtonLoading(targetButton);
83
+ }
63
84
  clearLoader('setLoader', loaderClasses);
64
85
  }
65
86
  };
87
+
@@ -1,19 +1,34 @@
1
1
  // recaptchaToken.js
2
+ 'use strict';
2
3
  import { showError } from '../ShowResponse.js';
3
4
 
5
+ /**
6
+ * Retrieves a reCAPTCHA v3 or Enterprise token safely.
7
+ * Checks for both grecaptcha.enterprise and standard grecaptcha v3.
8
+ *
9
+ * @param {string} [action='SUBMIT'] - The action name for the score.
10
+ * @returns {Promise<string|null>} Token string or null if unavailable.
11
+ */
4
12
  export const recaptchaToken = async (action = 'SUBMIT') => {
5
- let token = null;
13
+ if (typeof window === 'undefined') return null;
14
+ const siteKey = window.RECAPTCHA_SITE_KEY || window.recaptchaSiteKey || null;
15
+ if (!siteKey || !window.grecaptcha) return null;
6
16
 
7
- if (window.RECAPTCHA_SITE_KEY && window.grecaptcha && window.grecaptcha.enterprise) {
8
- try {
9
- token = await window.grecaptcha.enterprise.execute(
10
- window.RECAPTCHA_SITE_KEY,
11
- { action }
12
- );
13
- } catch (err) {
14
- showError(err);
17
+ try {
18
+ // 1. Check Google Cloud reCAPTCHA Enterprise
19
+ if (window.grecaptcha.enterprise && typeof window.grecaptcha.enterprise.execute === 'function') {
20
+ return await window.grecaptcha.enterprise.execute(siteKey, { action });
15
21
  }
22
+
23
+ // 2. Fallback to standard reCAPTCHA v3
24
+ if (typeof window.grecaptcha.execute === 'function') {
25
+ return await window.grecaptcha.execute(siteKey, { action });
26
+ }
27
+ } catch (err) {
28
+ showError(err);
29
+ console.warn('reCAPTCHA execution skipped due to error:', err.message);
16
30
  }
17
31
 
18
- return token;
32
+ return null;
19
33
  };
34
+
package/AcctMgt/update.js CHANGED
@@ -1,14 +1,17 @@
1
1
  // update.js
2
+ 'use strict';
2
3
  import { id } from '../UtilityHtml.js';
3
4
  import { bindEvent } from '../Utility.js';
4
5
  import { msgException, showError } from '../ShowResponse.js';
5
- import axios from 'axios';
6
+ import axios from '../axiosWrapper.js';
6
7
  import { redirectAfterDelay, parseErrorResponse } from '../general.js';
7
8
  import { recaptchaToken } from './recaptchaToken.js';
9
+ import { setButtonLoading, clearButtonLoading } from '../ButtonLoader.js';
10
+ import { swalSuccess, swalError } from '../Alert.js';
8
11
 
9
12
  export function update({
10
13
  formId,
11
- buttonId,
14
+ buttonId = 'button',
12
15
  route,
13
16
  redirect,
14
17
  recaptchaAction = 'SUBMIT'
@@ -16,53 +19,83 @@ export function update({
16
19
  const handler = async (e) => {
17
20
  e.preventDefault();
18
21
 
19
- const token = await recaptchaToken(recaptchaAction);
22
+ let btnEl = id(buttonId) || (typeof formId === 'string' ? id(formId)?.querySelector('button') : null);
20
23
 
21
- const form = id(formId);
22
- if (!form) {
23
- msgException(`Form with ID "${formId}" not found.`);
24
- return;
25
- }
24
+ try {
25
+ if (btnEl) {
26
+ setButtonLoading(btnEl);
27
+ }
26
28
 
27
- const formData = new FormData();
28
- const inputs = form.querySelectorAll('[name]');
29
+ const token = await recaptchaToken(recaptchaAction);
29
30
 
30
- if (token) {
31
- formData.append('recaptchaTokenV3', token);
32
- }
31
+ const form = id(formId);
32
+ if (!form) {
33
+ msgException(`Form with ID "${formId}" not found.`);
34
+ return;
35
+ }
33
36
 
34
- inputs.forEach(input => {
35
- const name = input.name;
36
- const original = input.getAttribute('data-original');
37
- const current = input.type === 'file' ? input.files[0] : input.value;
37
+ const formData = new FormData();
38
+ const inputs = form.querySelectorAll('[name]');
38
39
 
39
- if (input.type === 'file') {
40
- if (current) formData.append(name, current);
41
- } else if (current !== original) {
42
- formData.append(name, current);
40
+ if (token) {
41
+ formData.append('recaptchaTokenV3', token);
43
42
  }
44
- });
45
43
 
46
- try {
47
- const response = await axios.post(route, formData);
48
- const message = response.data.message;
44
+ inputs.forEach(input => {
45
+ const name = input.name;
46
+ const original = input.getAttribute('data-original');
47
+ const current = input.type === 'file' ? input.files[0] : input.value;
48
+
49
+ if (input.type === 'file') {
50
+ if (current) formData.append(name, current);
51
+ } else if (current !== original) {
52
+ formData.append(name, current);
53
+ }
54
+ });
55
+
56
+ const response = await axios.post(route, formData, {
57
+ baseURL: '/',
58
+ withCredentials: true,
59
+ });
60
+
61
+ const message = response?.data?.message || 'Updated successfully';
62
+ const cleanMessage = typeof message === 'string' ? message : 'Updated successfully';
49
63
 
50
- const notificationId = id(`${formId}_notification`) || formId;
51
- notificationId.style.display = 'block';
52
- notificationId.innerHTML = message;
53
- notificationId.scrollIntoView({ behavior: 'smooth' });
64
+ // Modern SweetAlert2 toast
65
+ swalSuccess(cleanMessage, true);
66
+
67
+ // Fallback DOM notification if element exists
68
+ const notificationId = id(`${formId}_notification`);
69
+ if (notificationId) {
70
+ notificationId.style.display = 'block';
71
+ notificationId.innerHTML = cleanMessage;
72
+ notificationId.scrollIntoView({ behavior: 'smooth' });
73
+ }
54
74
 
55
75
  if (redirect) {
56
- redirectAfterDelay(redirect, 2000);
76
+ redirectAfterDelay(redirect, 1500);
57
77
  }
58
78
 
59
79
  } catch (error) {
60
- const errorMsg = parseErrorResponse(error);
80
+ const errorMsg = parseErrorResponse(error) || 'An error occurred during update.';
81
+ showError(error);
82
+
83
+ // Modern SweetAlert2 error modal
84
+ swalError(errorMsg, true);
85
+
86
+ // Fallback DOM notification if element exists
61
87
  const notificationId = id(`${formId}_notification`);
62
- notificationId.style.display = 'block';
63
- notificationId.innerHTML = `<li style="color:white; background-color:red">${errorMsg}</li>`;
88
+ if (notificationId) {
89
+ notificationId.style.display = 'block';
90
+ notificationId.innerHTML = `<li style="color:white; background-color:red">${errorMsg}</li>`;
91
+ }
92
+ } finally {
93
+ if (btnEl) {
94
+ clearButtonLoading(btnEl);
95
+ }
64
96
  }
65
97
  };
66
98
 
67
99
  bindEvent({ id: buttonId, handler });
68
100
  }
101
+
package/Alert.js ADDED
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+ import Swal from 'sweetalert2';
3
+
4
+ /**
5
+ * Checks if running in browser environment (SSR safety guard).
6
+ */
7
+ const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
8
+
9
+ /**
10
+ * SweetAlert2 Toast Mixin with sensible modern defaults.
11
+ */
12
+ const Toast = Swal.mixin({
13
+ toast: true,
14
+ position: 'top-end',
15
+ showConfirmButton: false,
16
+ timer: 3500,
17
+ timerProgressBar: true,
18
+ didOpen: (toast) => {
19
+ toast.addEventListener('mouseenter', Swal.stopTimer);
20
+ toast.addEventListener('mouseleave', Swal.resumeTimer);
21
+ }
22
+ });
23
+
24
+ /**
25
+ * Display a modern SweetAlert2 toast notification.
26
+ *
27
+ * @param {string} message - Toast message to display
28
+ * @param {'success'|'error'|'warning'|'info'|'question'} [icon='success'] - Toast icon
29
+ * @param {number} [timer=3500] - Duration in ms
30
+ * @returns {Promise<any>}
31
+ */
32
+ export const swalToast = async (message, icon = 'success', timer = 3500) => {
33
+ if (!isBrowser()) return null;
34
+ try {
35
+ return await Toast.fire({
36
+ icon,
37
+ title: message,
38
+ timer
39
+ });
40
+ } catch (err) {
41
+ console.error('SwalToast Error:', err);
42
+ return null;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Display a success alert (toast by default, or modal if specified).
48
+ *
49
+ * @param {string} message - Success message
50
+ * @param {boolean} [asToast=true] - Whether to show as toast or modal
51
+ * @param {string} [title='Success'] - Modal title (only used when asToast=false)
52
+ * @returns {Promise<any>}
53
+ */
54
+ export const swalSuccess = async (message, asToast = true, title = 'Success') => {
55
+ if (!isBrowser()) return null;
56
+ try {
57
+ if (asToast) {
58
+ return await swalToast(message, 'success');
59
+ }
60
+ return await Swal.fire({
61
+ icon: 'success',
62
+ title,
63
+ text: message,
64
+ confirmButtonColor: '#3085d6',
65
+ confirmButtonText: 'OK'
66
+ });
67
+ } catch (err) {
68
+ console.error('SwalSuccess Error:', err);
69
+ return null;
70
+ }
71
+ };
72
+
73
+ /**
74
+ * Display an error alert (modal dialog by default for high visibility).
75
+ *
76
+ * @param {string} message - Error description/message
77
+ * @param {boolean} [isModal=true] - true for prominent modal, false for toast
78
+ * @param {string} [title='Error'] - Modal title
79
+ * @returns {Promise<any>}
80
+ */
81
+ export const swalError = async (message, isModal = true, title = 'Error') => {
82
+ if (!isBrowser()) return null;
83
+ try {
84
+ if (!isModal) {
85
+ return await swalToast(message, 'error');
86
+ }
87
+ return await Swal.fire({
88
+ icon: 'error',
89
+ title,
90
+ text: message,
91
+ confirmButtonColor: '#d33',
92
+ confirmButtonText: 'Dismiss'
93
+ });
94
+ } catch (err) {
95
+ console.error('SwalError Error:', err);
96
+ return null;
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Display a warning alert modal or toast.
102
+ *
103
+ * @param {string} message - Warning message
104
+ * @param {string} [title='Warning'] - Modal title
105
+ * @returns {Promise<any>}
106
+ */
107
+ export const swalWarning = async (message, title = 'Warning') => {
108
+ if (!isBrowser()) return null;
109
+ try {
110
+ return await Swal.fire({
111
+ icon: 'warning',
112
+ title,
113
+ text: message,
114
+ confirmButtonColor: '#f39c12',
115
+ confirmButtonText: 'Understood'
116
+ });
117
+ } catch (err) {
118
+ console.error('SwalWarning Error:', err);
119
+ return null;
120
+ }
121
+ };
122
+
123
+ /**
124
+ * Display an interactive confirmation modal dialog.
125
+ *
126
+ * @param {string} title - Prompt title
127
+ * @param {string} text - Detailed prompt text or warning
128
+ * @param {string} [confirmButtonText='Yes, proceed'] - Label for confirm action
129
+ * @param {string} [cancelButtonText='Cancel'] - Label for cancel action
130
+ * @returns {Promise<boolean>} Resolves to true if user confirmed, false otherwise
131
+ */
132
+ export const swalConfirm = async (
133
+ title,
134
+ text,
135
+ confirmButtonText = 'Yes, proceed',
136
+ cancelButtonText = 'Cancel'
137
+ ) => {
138
+ if (!isBrowser()) return false;
139
+ try {
140
+ const result = await Swal.fire({
141
+ title,
142
+ text,
143
+ icon: 'warning',
144
+ showCancelButton: true,
145
+ confirmButtonColor: '#3085d6',
146
+ cancelButtonColor: '#d33',
147
+ confirmButtonText,
148
+ cancelButtonText,
149
+ reverseButtons: true
150
+ });
151
+ return !!result.isConfirmed;
152
+ } catch (err) {
153
+ console.error('SwalConfirm Error:', err);
154
+ return false;
155
+ }
156
+ };
157
+
158
+ export { Swal };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ import axios from './axiosWrapper.js';
3
+ import { swalConfirm } from './Alert.js';
4
+
5
+ let isInterceptorInstalled = false;
6
+
7
+ /**
8
+ * Installs an Axios response interceptor that automatically handles HTTP 401 Unauthorized errors.
9
+ * Prompts user with SweetAlert2 modal and redirects to login if confirmed.
10
+ *
11
+ * @param {Object} [options={}]
12
+ * @param {string} [options.loginUrl='/login'] - Redirect destination on 401
13
+ * @param {string} [options.message='Your session has expired. Please log in again to continue.']
14
+ * @param {string} [options.title='Session Expired']
15
+ */
16
+ export const setupAuthExpiryInterceptor = (options = {}) => {
17
+ if (typeof window === 'undefined' || isInterceptorInstalled) return;
18
+ isInterceptorInstalled = true;
19
+
20
+ const {
21
+ loginUrl = '/login',
22
+ message = 'Your session has expired. Please log in again to continue.',
23
+ title = 'Session Expired'
24
+ } = options;
25
+
26
+ axios.interceptors.response.use(
27
+ (response) => response,
28
+ async (error) => {
29
+ const status = error?.response?.status;
30
+
31
+ if (status === 401) {
32
+ try {
33
+ const confirmed = await swalConfirm(
34
+ title,
35
+ message,
36
+ 'Log In',
37
+ 'Stay on Page'
38
+ );
39
+
40
+ if (confirmed) {
41
+ // Save current page URL so caller can redirect back after login
42
+ try {
43
+ sessionStorage.setItem('post_login_redirect', window.location.href);
44
+ } catch (_) {}
45
+
46
+ window.location.href = loginUrl;
47
+ }
48
+ } catch (err) {
49
+ console.error('setupAuthExpiryInterceptor Error:', err);
50
+ }
51
+ }
52
+
53
+ return Promise.reject(error);
54
+ }
55
+ );
56
+ };
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+ import { id } from './UtilityHtml.js';
3
+
4
+ /**
5
+ * Universal SVG Spinner markup (framework agnostic, responsive, lightweight).
6
+ */
7
+ const DEFAULT_SPINNER_SVG = `
8
+ <svg class="mmlib-btn-spinner" style="display:inline-block; vertical-align:middle; width:1.15em; height:1.15em; animation:mmlib-spin 0.75s linear infinite; margin-right:0.5em;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
9
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" stroke-opacity="0.25"/>
10
+ <path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
11
+ </svg>
12
+ `;
13
+
14
+ /**
15
+ * Inject required spinner keyframe animation once into document head if not present.
16
+ */
17
+ const ensureSpinnerStyles = () => {
18
+ if (typeof document === 'undefined') return;
19
+ const styleId = 'mmlib-button-spinner-styles';
20
+ if (!document.getElementById(styleId)) {
21
+ const style = document.createElement('style');
22
+ style.id = styleId;
23
+ style.textContent = `
24
+ @keyframes mmlib-spin {
25
+ from { transform: rotate(0deg); }
26
+ to { transform: rotate(360deg); }
27
+ }
28
+ `;
29
+ document.head?.appendChild(style);
30
+ }
31
+ };
32
+
33
+ /**
34
+ * Resolve target button element by ID, CSS selector, or Element reference.
35
+ * Supports any button type (type="button", type="submit", etc.)
36
+ * @param {HTMLElement|string} btn
37
+ * @returns {HTMLElement|null}
38
+ */
39
+ export const resolveButton = (btn) => {
40
+ if (typeof btn === 'string') {
41
+ const trimmed = btn.trim();
42
+ return id(trimmed.replace(/^#/, '')) || (typeof document !== 'undefined' ? document.querySelector(trimmed) : null);
43
+ }
44
+ return btn instanceof HTMLElement ? btn : null;
45
+ };
46
+
47
+ /**
48
+ * Sets a button into a loading state:
49
+ * - Disables the button to prevent duplicate submissions
50
+ * - Injects a smooth spinning SVG
51
+ * - Preserves original text/HTML for later restoration
52
+ *
53
+ * @param {HTMLElement|string} btn - Button element or ID string
54
+ * @param {string} [loadingText=''] - Optional text to display next to spinner
55
+ * @returns {boolean} True if successfully set to loading state
56
+ */
57
+ export const setButtonLoading = (btn, loadingText = '') => {
58
+ try {
59
+ const el = resolveButton(btn);
60
+ if (!el) return false;
61
+
62
+ ensureSpinnerStyles();
63
+
64
+ // Avoid overriding if already in loading state
65
+ if (!el.hasAttribute('data-mmlib-loading')) {
66
+ el.setAttribute('data-mmlib-loading', 'true');
67
+ el.setAttribute('data-original-html', el.innerHTML);
68
+ el.setAttribute('data-original-disabled', el.disabled ? 'true' : 'false');
69
+ }
70
+
71
+ el.disabled = true;
72
+ el.setAttribute('aria-busy', 'true');
73
+
74
+ const displayText = loadingText ? `<span>${loadingText}</span>` : el.getAttribute('data-original-html') || '';
75
+ el.innerHTML = `${DEFAULT_SPINNER_SVG}${displayText}`;
76
+
77
+ return true;
78
+ } catch (err) {
79
+ console.error('setButtonLoading Error:', err);
80
+ return false;
81
+ }
82
+ };
83
+
84
+ /**
85
+ * Restores a button from its loading state back to its original HTML and state.
86
+ *
87
+ * @param {HTMLElement|string} btn - Button element or ID string
88
+ * @returns {boolean} True if successfully restored
89
+ */
90
+ export const clearButtonLoading = (btn) => {
91
+ try {
92
+ const el = resolveButton(btn);
93
+ if (!el) return false;
94
+
95
+ if (el.hasAttribute('data-mmlib-loading')) {
96
+ const originalHtml = el.getAttribute('data-original-html');
97
+ const wasDisabledBefore = el.getAttribute('data-original-disabled') === 'true';
98
+
99
+ if (originalHtml !== null) {
100
+ el.innerHTML = originalHtml;
101
+ }
102
+
103
+ el.disabled = wasDisabledBefore;
104
+ el.removeAttribute('aria-busy');
105
+ el.removeAttribute('data-mmlib-loading');
106
+ el.removeAttribute('data-original-html');
107
+ el.removeAttribute('data-original-disabled');
108
+ return true;
109
+ }
110
+
111
+ return false;
112
+ } catch (err) {
113
+ console.error('clearButtonLoading Error:', err);
114
+ return false;
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Helper to wrap any async action with automatic button loading state and guaranteed cleanup.
120
+ *
121
+ * @param {HTMLElement|string} btn - Button element or ID
122
+ * @param {() => Promise<any>} asyncAction - Async callback function to execute
123
+ * @param {string} [loadingText=''] - Optional loading text
124
+ * @returns {Promise<any>} Result of asyncAction
125
+ */
126
+ export const withButtonSpinner = async (btn, asyncAction, loadingText = '') => {
127
+ setButtonLoading(btn, loadingText);
128
+ try {
129
+ return await asyncAction();
130
+ } finally {
131
+ clearButtonLoading(btn);
132
+ }
133
+ };