diff --git a/.github/workflows/update-wp-one-package.yml b/.github/workflows/update-wp-one-package.yml
new file mode 100644
index 00000000..d19bef1a
--- /dev/null
+++ b/.github/workflows/update-wp-one-package.yml
@@ -0,0 +1,50 @@
+name: Update wp-one-package
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'New version of elementor/wp-one-package (e.g. 1.0.51)'
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ update-package:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout source branch
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.CLOUD_DEVOPS_TOKEN }}
+
+ - name: Create branch
+ run: |
+ BRANCH_NAME="update/wp-one-package-${{ inputs.version }}"
+ git checkout -b "${BRANCH_NAME}"
+ echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV
+
+ - name: Update wp-one-package version in composer.json
+ run: |
+ jq '.require["elementor/wp-one-package"] = "${{ inputs.version }}"' composer.json > composer.tmp && mv composer.tmp composer.json
+
+ - name: Commit and push changes
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add composer.json
+ git commit -m "Update elementor/wp-one-package to ${{ inputs.version }}"
+ git push origin "${BRANCH_NAME}"
+
+ - name: Create Pull Request
+ env:
+ GH_TOKEN: ${{ secrets.CLOUD_DEVOPS_TOKEN }}
+ run: |
+ gh pr create \
+ --base "${{ github.ref_name }}" \
+ --head "${BRANCH_NAME}" \
+ --title "Update elementor/wp-one-package to ${{ inputs.version }}" \
+ --body "Updates \`elementor/wp-one-package\` composer package to version \`${{ inputs.version }}\`."
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 3867a0fe..39c0ee35 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,19 @@
-npm run lint
+# Get list of staged files
+STAGED_JS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$' || true)
+STAGED_PHP_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.php$' || true)
+STAGED_CSS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(css|scss)' || true)
+
+# Run JS linting if there are staged JS files
+if [ -n "$STAGED_JS_FILES" ]; then
+ npm run lint:js || exit 1
+fi
+
+# Run PHP linting if there are staged PHP files
+if [ -n "$STAGED_PHP_FILES" ]; then
+ npm run lint:php:report || exit 1
+fi
+
+# Run CSS linting if there are staged CSS files
+if [ -n "$STAGED_CSS_FILES" ]; then
+ npm run lint:css || exit 1
+fi
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index 63b178a1..00000000
--- a/.npmrc
+++ /dev/null
@@ -1,2 +0,0 @@
-//npm.pkg.github.com/:_authToken=${token}
-//@elementor:registry=https://npm.pkg.github.com
diff --git a/README.md b/README.md
index 950d75f0..b798a5fd 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
**Requires at least:** 6.6 \
**Tested up to:** 6.9 \
**Requires PHP:** 7.4 \
-**Stable tag:** 4.0.1 \
+**Stable tag:** 4.1.0 \
**License:** GPLv2 or later
Ally: Make your site more inclusive by scanning for accessibility violations, fixing them easily, and adding a usability widget and accessibility statement.
@@ -227,6 +227,26 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro
## Changelog
+### 4.1.0 – 2026-02-23
+
+* New: Added bulk remediation flow to generate AI alt text or mark multiple images as decorative
+* Tweak: Added the ability to disable the accessibility widget to prevent it from loading on your site
+* Tweak: Security enhancement to prevent potential SQL injection
+* Tweak: Security enhancement to remove unsecure composer package
+* Fix: Display WordPress admin notices inside the settings page
+* Fix: Resolved conflict in Beaver Builder by preventing remediation runner from executing during page builder sessions
+
+### 4.0.3 – 2026-01-28
+
+* Tweak: Security enhancements for access control
+
+### 4.0.2 – 2026-01-28
+
+* Tweak: Added a dashboard widget to trigger scans and view results
+* Fix: Resolved layout issues on the settings page for RTL websites
+* Fix: Resolved widget trigger functionality when users are logged out
+* Fix: Missing styles warning when reviews popup is displayed
+
### 4.0.1 – 2026-01-20
* Fix: Fix ally widget action to toggle open/close state.
diff --git a/assets/dev/js/api/exceptions/APIError.js b/assets/dev/js/api/exceptions/APIError.js
index 2750d975..9b85fd64 100644
--- a/assets/dev/js/api/exceptions/APIError.js
+++ b/assets/dev/js/api/exceptions/APIError.js
@@ -1,8 +1,10 @@
class APIError extends Error {
- constructor(message) {
+ constructor(message, code = null, data = null) {
super(message);
this.name = 'APIError';
+ this.code = code;
+ this.data = data;
}
}
diff --git a/assets/dev/js/api/index.js b/assets/dev/js/api/index.js
index bb7b613c..cb9e8513 100644
--- a/assets/dev/js/api/index.js
+++ b/assets/dev/js/api/index.js
@@ -24,16 +24,23 @@ class API {
}
if (!response.success) {
- throw new APIError(response.data.message);
+ throw new APIError(
+ response.data.message,
+ response.data.code,
+ response.data,
+ );
}
return response.data;
} catch (e) {
if (e instanceof APIError) {
throw e;
- } else {
- throw new APIError(e.message);
}
+
+ // apiFetch throws an error with code and message at root level
+ // when WordPress REST API returns a WP_Error
+ // WordPress REST API error structure: { code, message, data }
+ throw new APIError(e.message, e.code, e.data);
}
}
diff --git a/assets/dev/js/services/mixpanel/mixpanel-events.js b/assets/dev/js/services/mixpanel/mixpanel-events.js
index 9e5b25fa..304cbcf9 100644
--- a/assets/dev/js/services/mixpanel/mixpanel-events.js
+++ b/assets/dev/js/services/mixpanel/mixpanel-events.js
@@ -76,4 +76,8 @@ export const mixpanelEvents = {
// Heading Structure
headingClicked: 'heading_clicked',
headingSelected: 'heading_selected',
+
+ // Bulk Alt Text
+ stopButtonClicked: 'stop_button_clicked',
+ bulkAltTextClicked: 'bulk_alt_text_clicked',
};
diff --git a/assets/dev/js/services/mixpanel/mixpanel-service.js b/assets/dev/js/services/mixpanel/mixpanel-service.js
index 880fec3b..e147cf6a 100644
--- a/assets/dev/js/services/mixpanel/mixpanel-service.js
+++ b/assets/dev/js/services/mixpanel/mixpanel-service.js
@@ -1,8 +1,8 @@
-import mixpanel from 'mixpanel-browser';
-
const SHARE_USAGE_DATA = 'share_usage_data';
const MIXPANEL_TOKEN = '150605b3b9f979922f2ac5a52e2dcfe9';
+let mixpanel = null;
+
const init = async () => {
const { ea11ySettingsData, ea11yScannerData } = window;
const planData = ea11ySettingsData?.planData || ea11yScannerData?.planData;
@@ -16,6 +16,14 @@ const init = async () => {
return;
}
+ // Lazy load mixpanel
+ if (!mixpanel) {
+ const mixpanelModule = await import(
+ /* webpackChunkName: "chunk-mixpanel-browser" */ 'mixpanel-browser'
+ );
+ mixpanel = mixpanelModule.default;
+ }
+
const pluginEnv = ea11ySettingsData?.pluginEnv || ea11yScannerData?.pluginEnv;
const pluginVersion =
ea11ySettingsData?.pluginVersion || ea11yScannerData?.pluginVersion;
@@ -24,6 +32,8 @@ const init = async () => {
debug: pluginEnv === 'dev',
track_pageview: false,
persistence: 'localStorage',
+ record_sessions_percent: 2,
+ record_heatmap_data: true,
});
mixpanel.register({
@@ -51,7 +61,7 @@ const init = async () => {
};
const sendEvent = (name, event) => {
- if (mixpanel.__loaded) {
+ if (mixpanel?.__loaded) {
mixpanel.track(name, event);
}
};
diff --git a/classes/client/client-response.php b/classes/client/client-response.php
new file mode 100644
index 00000000..4227b6aa
--- /dev/null
+++ b/classes/client/client-response.php
@@ -0,0 +1,45 @@
+response ) ) {
+ return $this->response;
+ }
+
+ $message = $this->response->get_error_message();
+
+ if ( isset( $this->known_errors[ $message ] ) ) {
+ throw $this->known_errors[ $message ];
+ }
+
+ throw new Exception( $message );
+ }
+
+ public function __construct( $response ) {
+ $this->known_errors = [
+ "Quota Status Guard Request Failed!: plan.features.ai_credits Quota exceeded" => new Quota_Exceeded_Error(),
+ "Quota Api Request Failed!: Failed checking if allowed to use quota" => new Quota_API_Error(),
+ ];
+
+ $this->response = $response;
+ }
+}
diff --git a/classes/exceptions/quota-api-error.php b/classes/exceptions/quota-api-error.php
new file mode 100644
index 00000000..57f95e92
--- /dev/null
+++ b/classes/exceptions/quota-api-error.php
@@ -0,0 +1,13 @@
+is_builders_view() ) {
+ return false;
+ }
+
// Skip remediation during template_redirect AJAX requests
if ( $this->is_template_redirect_ajax_request() ) {
return false;
diff --git a/modules/remediation/database/remediation-entry.php b/modules/remediation/database/remediation-entry.php
index 4a7dbc28..9a61f08e 100644
--- a/modules/remediation/database/remediation-entry.php
+++ b/modules/remediation/database/remediation-entry.php
@@ -212,7 +212,11 @@ public static function get_global_remediations( string $url ) : array {
'operator' => '=',
],
];
- $join = "LEFT JOIN $excluded_table ON $remediation_table.id = $excluded_table.remediation_id AND $excluded_table.page_url = '$url'";
+ // Use prepare() to safely bind the URL; never concatenate user input into SQL.
+ $join = Remediation_Table::db()->prepare(
+ "LEFT JOIN $excluded_table ON $remediation_table.id = $excluded_table.remediation_id AND $excluded_table.page_url = %s",
+ $url
+ );
return Remediation_Table::select( "$remediation_table.*, COALESCE($excluded_table.active, $remediation_table.active) AS active_for_page", $global_where, null, null, $join );
}
diff --git a/modules/scanner/assets/js/api/APIScanner.js b/modules/scanner/assets/js/api/APIScanner.js
index 8b84b6c3..269c0450 100644
--- a/modules/scanner/assets/js/api/APIScanner.js
+++ b/modules/scanner/assets/js/api/APIScanner.js
@@ -73,12 +73,16 @@ export class APIScanner extends API {
});
}
- static async generateAltText(data) {
- return APIScanner.request({
+ static async generateAltText(data, signal = null) {
+ const config = {
method: 'POST',
path: `${v1Prefix}/scanner/generate-alt-text`,
data,
- });
+ };
+ if (signal) {
+ config.signal = signal;
+ }
+ return APIScanner.request(config);
}
static async resolveWithAI(data) {
diff --git a/modules/scanner/assets/js/components/alt-text-form/index.js b/modules/scanner/assets/js/components/alt-text-form/index.js
index aa77e3bc..13612472 100644
--- a/modules/scanner/assets/js/components/alt-text-form/index.js
+++ b/modules/scanner/assets/js/components/alt-text-form/index.js
@@ -17,6 +17,7 @@ import PropTypes from 'prop-types';
import { useToastNotification } from '@ea11y-apps/global/hooks';
import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
import { ImagePreview } from '@ea11y-apps/scanner/components/alt-text-form/image-preview';
+import BulkAltTextBanner from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-alt-text-banner';
import { SetGlobal } from '@ea11y-apps/scanner/components/manage-footer-actions/page/set-global';
import { UpgradeContent } from '@ea11y-apps/scanner/components/upgrade-info-tip/upgrade-content';
import { AI_QUOTA_LIMIT, IS_PRO_PLAN } from '@ea11y-apps/scanner/constants';
@@ -82,158 +83,162 @@ export const AltTextForm = ({ item, current, setCurrent, setIsEdit }) => {
: __('Apply fix', 'pojo-accessibility');
return (
-
+ <>
+
+
+
-
-
-
-
-
-
- {__('Mark image as decorative', 'pojo-accessibility')}
-
-
- {__(
- "(decorative images don't need description)",
+
+
+
+
+ {__('Mark image as decorative', 'pojo-accessibility')}
+
+
+ {__(
+ "(decorative images don't need description)",
+ 'pojo-accessibility',
+ )}
+
+
+
+ {!data?.[current]?.makeDecorative ? (
+
-
-
- {!data?.[current]?.makeDecorative ? (
-
- {IS_PRO_PLAN && AI_QUOTA_LIMIT ? (
-
+ {IS_PRO_PLAN && AI_QUOTA_LIMIT ? (
+
-
- {loadingAiText ? (
-
- ) : (
-
- )}
-
-
- ) : (
- }
- >
-
+ {loadingAiText ? (
+
+ ) : (
+
+ )}
+
+
+ ) : (
+ }
>
-
-
-
- )}
-
- ),
- }}
- />
- ) : (
-
-
-
- {__('no description needed', 'pojo-accessibility')}
-
-
- )}
-
-
-
- {__('Tips:', 'pojo-accessibility')}
-
- {__(
- "Keep descriptions short and simple, describing what the image shows or why it's on the page.",
- 'pojo-accessibility',
- )}
-
-
-
- {!isManage && (
-
+
+
+
+ )}
+
+ ),
+ }}
/>
+ ) : (
+
+
+
+ {__('no description needed', 'pojo-accessibility')}
+
+
)}
+
+
+
+ {__('Tips:', 'pojo-accessibility')}
+
+ {__(
+ "Keep descriptions short and simple, describing what the image shows or why it's on the page.",
+ 'pojo-accessibility',
+ )}
+
+
+
+ {!isManage && (
+
+ )}
-
- {isManage && (
-
-
-
+
+ >
);
};
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-banner.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-banner.js
new file mode 100644
index 00000000..baa46678
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-banner.js
@@ -0,0 +1,63 @@
+import Chip from '@elementor/ui/Chip';
+import Grid from '@elementor/ui/Grid';
+import Typography from '@elementor/ui/Typography';
+import { styled } from '@elementor/ui/styles';
+import BulkAltTextButton from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-alt-text-button';
+import BulkBannerImageStack from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-banner-image-stack';
+import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context';
+import bulk1 from '@ea11y-apps/scanner/images/bulk-1.png';
+import bulk2 from '@ea11y-apps/scanner/images/bulk-2.png';
+import bulk3 from '@ea11y-apps/scanner/images/bulk-3.png';
+import bulk4 from '@ea11y-apps/scanner/images/bulk-4.png';
+import { sprintf, _n } from '@wordpress/i18n';
+
+const BulkAltTextBanner = () => {
+ const { sortedViolations } = useScannerWizardContext();
+ const bulkImages = [bulk1, bulk2, bulk3, bulk4];
+
+ if (sortedViolations.altText.length <= 2) {
+ return null;
+ }
+
+ const imagesToShow =
+ sortedViolations.altText.length > 3
+ ? 4
+ : Number(sortedViolations.altText.length);
+
+ return (
+
+
+
+
+ {sprintf(
+ // Translators: %d number of images
+ _n('%d image', '%d images', 14, 'pojo-accessibility'),
+ sortedViolations.altText.length,
+ )}
+
+ }
+ size="tiny"
+ color="info"
+ sx={{ marginInlineStart: -2 }}
+ />
+
+
+
+
+
+ );
+};
+
+export default BulkAltTextBanner;
+
+const StyledBannerGrid = styled(Grid)(({ theme }) => ({
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingInline: theme.spacing(3),
+ flexWrap: 'nowrap',
+ marginBottom: theme.spacing(2),
+ boxShadow: 'rgba(0, 0, 0, 0.12) 0px 10px 14px -8px',
+ paddingBlock: theme.spacing(1.25),
+}));
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-button.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-button.js
new file mode 100644
index 00000000..fa221155
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-button.js
@@ -0,0 +1,71 @@
+import CrownFilledIcon from '@elementor/icons/CrownFilledIcon';
+import Button from '@elementor/ui/Button';
+import Infotip from '@elementor/ui/Infotip';
+import { useModal } from '@ea11y/hooks';
+import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events';
+import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service';
+import BulkAltTextManager from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-alt-text-manager';
+import UpgradeInfotip from '@ea11y-apps/scanner/components/bulk-alt-text/upgrade-infotip';
+import { IS_PRO_PLAN } from '@ea11y-apps/scanner/constants';
+import WandIcon from '@ea11y-apps/scanner/icons/wand-icon';
+import { __ } from '@wordpress/i18n';
+
+const BulkAltTextButton = () => {
+ const { open, close, isOpen } = useModal(false);
+
+ const handleBulkAltTextClick = () => {
+ mixpanelService.sendEvent(mixpanelEvents.bulkAltTextClicked);
+ open();
+ };
+
+ if (!IS_PRO_PLAN) {
+ return (
+
+ }
+ placement="bottom"
+ PopperProps={{
+ disablePortal: true,
+ sx: { width: 300 },
+ }}
+ >
+ }
+ size="small"
+ >
+ {__('Bulk alt text', 'pojo-accessibility')}
+
+
+ );
+ }
+
+ return (
+ <>
+ }
+ size="small"
+ onClick={handleBulkAltTextClick}
+ aria-label={__('Open bulk alt text manager', 'pojo-accessibility')}
+ >
+ {__('Bulk alt text', 'pojo-accessibility')}
+
+
+ >
+ );
+};
+
+export default BulkAltTextButton;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-manager.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-manager.js
new file mode 100644
index 00000000..81f2e6fa
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-manager.js
@@ -0,0 +1,315 @@
+import Button from '@elementor/ui/Button';
+import Dialog from '@elementor/ui/Dialog';
+import DialogActions from '@elementor/ui/DialogActions';
+import DialogContent from '@elementor/ui/DialogContent';
+import DialogHeader from '@elementor/ui/DialogHeader';
+import DialogTitle from '@elementor/ui/DialogTitle';
+import Divider from '@elementor/ui/Divider';
+import { FocusTrap } from 'focus-trap-react';
+import PropTypes from 'prop-types';
+import { useToastNotification } from '@ea11y-apps/global/hooks';
+import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
+import ConfirmCloseDialog from '@ea11y-apps/scanner/components/bulk-alt-text/confirm-close-dialog';
+import ConfirmStopGenerationDialog from '@ea11y-apps/scanner/components/bulk-alt-text/confirm-stop-generation-dialog';
+import ImageGrid from '@ea11y-apps/scanner/components/bulk-alt-text/image-grid';
+import BulkAltTextProgress from '@ea11y-apps/scanner/components/bulk-alt-text/progress-bar';
+import QuotaErrorAlert from '@ea11y-apps/scanner/components/bulk-alt-text/quota-error-alert';
+import { BLOCKS } from '@ea11y-apps/scanner/constants';
+import { BulkGenerationProvider } from '@ea11y-apps/scanner/context/bulk-generation-context';
+import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context';
+import usePreventAriaHidden from '@ea11y-apps/scanner/hooks/use-prevent-aria-hidden';
+import WandIcon from '@ea11y-apps/scanner/icons/wand-icon';
+import { submitAltTextRemediation } from '@ea11y-apps/scanner/utils/submit-alt-text';
+import { speak } from '@wordpress/a11y';
+import { useState, useCallback, useRef, useMemo } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+const BulkAltTextManager = ({ open, close }) => {
+ usePreventAriaHidden(open);
+
+ const { success, error } = useToastNotification();
+ const {
+ sortedViolations,
+ altTextData,
+ setAltTextData,
+ resolved,
+ setResolved,
+ currentScanId,
+ updateRemediationList,
+ isManage,
+ } = useScannerWizardContext();
+ const [loading, setLoading] = useState(false);
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [showStopGenerationDialog, setShowStopGenerationDialog] =
+ useState(false);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const stopGenerationFnRef = useRef(null);
+ const altTextViolations = sortedViolations.altText;
+ const type = isManage ? 'manage' : 'main';
+
+ const selectedItemsCount = useMemo(() => {
+ let count = 0;
+ for (let i = 0; i < altTextViolations.length; i++) {
+ const itemData = altTextData?.[type]?.[i];
+ if (itemData?.selected === true && itemData?.hasValidAltText === true) {
+ count++;
+ }
+ }
+ return count;
+ }, [altTextViolations, altTextData, type]);
+
+ const hasUnsavedChanges = selectedItemsCount > 0;
+
+ const handleClose = () => {
+ mixpanelService.sendEvent(mixpanelEvents.popupButtonClicked, {
+ popupType: 'bulk_alt_text',
+ buttonName: 'x_button',
+ });
+ if (isGenerating) {
+ setShowStopGenerationDialog(true);
+ } else if (hasUnsavedChanges && !loading) {
+ setShowConfirmDialog(true);
+ } else {
+ close();
+ }
+ };
+
+ const handleLeaveWhileGenerating = () => {
+ mixpanelService.sendEvent(mixpanelEvents.popupButtonClicked, {
+ popupType: 'bulk_alt_text',
+ buttonName: 'Leave without generating',
+ });
+ if (stopGenerationFnRef.current) {
+ stopGenerationFnRef.current();
+ }
+ setShowStopGenerationDialog(false);
+ close();
+ };
+
+ const handleKeepGenerating = () => {
+ setShowStopGenerationDialog(false);
+ };
+
+ const handleGeneratingChange = useCallback((generating, stopFn) => {
+ setIsGenerating(generating);
+ stopGenerationFnRef.current = stopFn;
+ }, []);
+
+ const handleDiscard = () => {
+ mixpanelService.sendEvent(mixpanelEvents.popupButtonClicked, {
+ popupType: 'bulk_alt_text',
+ buttonName: 'Discard changes',
+ });
+ setShowConfirmDialog(false);
+ close();
+ };
+
+ const handleApply = async (
+ closeAfter = false,
+ source = 'bulk_main_action',
+ ) => {
+ setLoading(true);
+ let successCount = 0;
+ let errorCount = 0;
+ const updatedData = [...(altTextData?.[type] || [])];
+
+ try {
+ for (let i = 0; i < altTextViolations.length; i++) {
+ const item = altTextViolations[i];
+ const itemData = altTextData?.[type]?.[i];
+
+ if (!itemData?.selected || !itemData?.hasValidAltText) {
+ continue;
+ }
+
+ const isDecorative = itemData.makeDecorative || false;
+ if (!isDecorative && !itemData.altText?.trim()) {
+ console.warn(`Skipping item ${i}: No alt text provided`);
+ errorCount++;
+ continue;
+ }
+
+ try {
+ const remediation = await submitAltTextRemediation({
+ item,
+ altText: itemData.altText || '',
+ makeDecorative: isDecorative,
+ isGlobal: itemData.isGlobal || item.global || false,
+ apiId: itemData.apiId,
+ currentScanId,
+ updateRemediationList,
+ });
+ updatedData[i] = {
+ ...(updatedData[i] || {}),
+ remediation,
+ resolved: true,
+ };
+ successCount++;
+ } catch (e) {
+ errorCount++;
+ console.error(`Failed to submit item ${i}:`, e);
+ }
+ }
+
+ setAltTextData({
+ ...altTextData,
+ [type]: updatedData,
+ });
+
+ setResolved(resolved + successCount);
+
+ // Send mixpanel event for successful submissions
+ if (successCount > 0) {
+ mixpanelService.sendEvent(mixpanelEvents.applyFixButtonClicked, {
+ fix_method: 'Bulk Alt Text',
+ source,
+ num_of_images: successCount,
+ category_name: BLOCKS.altText,
+ page_url: window.ea11yScannerData?.pageData?.url,
+ is_global: 'no',
+ });
+ }
+
+ if (successCount > 0 && errorCount === 0) {
+ const message = __(
+ 'All selected items applied successfully!',
+ 'pojo-accessibility',
+ );
+ success(message);
+ speak(message, 'assertive');
+ setShowConfirmDialog(false);
+ close();
+ } else if (successCount > 0 && errorCount > 0) {
+ const message = sprintf(
+ // Translators: %1$d successful count, %2$d failed count
+ __(
+ '%1$d items applied successfully. %2$d items failed.',
+ 'pojo-accessibility',
+ ),
+ successCount,
+ errorCount,
+ );
+ success(message);
+ speak(message, 'assertive');
+ if (closeAfter) {
+ setShowConfirmDialog(false);
+ close();
+ }
+ } else if (errorCount > 0) {
+ const message = __(
+ 'Failed to apply items. Please try again.',
+ 'pojo-accessibility',
+ );
+ error(message);
+ speak(message, 'assertive');
+ }
+ } catch (e) {
+ console.error(e);
+ const message = __(
+ 'An error occurred while applying changes.',
+ 'pojo-accessibility',
+ );
+ error(message);
+ speak(message, 'assertive');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleApplyAndClose = () => {
+ handleApply(true, 'bulk_close_popup');
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ setShowConfirmDialog(false)}
+ loading={loading}
+ />
+ >
+ );
+};
+
+BulkAltTextManager.propTypes = {
+ open: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+};
+
+export default BulkAltTextManager;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-banner-image-stack.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-banner-image-stack.js
new file mode 100644
index 00000000..310244e1
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-banner-image-stack.js
@@ -0,0 +1,28 @@
+import Image from '@elementor/ui/Image';
+import PropTypes from 'prop-types';
+import { memo } from '@wordpress/element';
+
+const BulkBannerImageStack = ({ images, count }) => {
+ return (
+ <>
+ {Array.from({ length: count }).map((_, index) => (
+ 0 ? -2 : 0,
+ }}
+ />
+ ))}
+ >
+ );
+};
+
+BulkBannerImageStack.propTypes = {
+ images: PropTypes.arrayOf(PropTypes.string).isRequired,
+ count: PropTypes.number.isRequired,
+};
+
+export default memo(BulkBannerImageStack);
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/confirm-close-dialog.js b/modules/scanner/assets/js/components/bulk-alt-text/confirm-close-dialog.js
new file mode 100644
index 00000000..7a80598e
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/confirm-close-dialog.js
@@ -0,0 +1,68 @@
+import Button from '@elementor/ui/Button';
+import Dialog from '@elementor/ui/Dialog';
+import DialogActions from '@elementor/ui/DialogActions';
+import DialogContent from '@elementor/ui/DialogContent';
+import DialogTitle from '@elementor/ui/DialogTitle';
+import Typography from '@elementor/ui/Typography';
+import { FocusTrap } from 'focus-trap-react';
+import PropTypes from 'prop-types';
+import { __ } from '@wordpress/i18n';
+
+const ConfirmCloseDialog = ({
+ open,
+ onDiscard,
+ onApply,
+ onCancel,
+ loading,
+}) => {
+ return (
+
+
+
+ );
+};
+
+ConfirmCloseDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onDiscard: PropTypes.func.isRequired,
+ onApply: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ loading: PropTypes.bool.isRequired,
+};
+
+export default ConfirmCloseDialog;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/confirm-stop-generation-dialog.js b/modules/scanner/assets/js/components/bulk-alt-text/confirm-stop-generation-dialog.js
new file mode 100644
index 00000000..50e1d1c3
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/confirm-stop-generation-dialog.js
@@ -0,0 +1,58 @@
+import Button from '@elementor/ui/Button';
+import Dialog from '@elementor/ui/Dialog';
+import DialogActions from '@elementor/ui/DialogActions';
+import DialogContent from '@elementor/ui/DialogContent';
+import DialogTitle from '@elementor/ui/DialogTitle';
+import Typography from '@elementor/ui/Typography';
+import { FocusTrap } from 'focus-trap-react';
+import PropTypes from 'prop-types';
+import { __ } from '@wordpress/i18n';
+
+const ConfirmStopGenerationDialog = ({ open, onKeepGenerating, onLeave }) => {
+ return (
+
+
+
+ );
+};
+
+ConfirmStopGenerationDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onKeepGenerating: PropTypes.func.isRequired,
+ onLeave: PropTypes.func.isRequired,
+};
+
+export default ConfirmStopGenerationDialog;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/ai-generate-button.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/ai-generate-button.js
new file mode 100644
index 00000000..e1330a8f
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/ai-generate-button.js
@@ -0,0 +1,95 @@
+import AIIcon from '@elementor/icons/AIIcon';
+import IconButton from '@elementor/ui/IconButton';
+import Infotip from '@elementor/ui/Infotip';
+import InputAdornment from '@elementor/ui/InputAdornment';
+import Tooltip from '@elementor/ui/Tooltip';
+import PropTypes from 'prop-types';
+import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events';
+import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service';
+import { UpgradeContent } from '@ea11y-apps/scanner/components/upgrade-info-tip/upgrade-content';
+import { AI_QUOTA_LIMIT, IS_PRO_PLAN } from '@ea11y-apps/scanner/constants';
+import { __ } from '@wordpress/i18n';
+
+const AIGenerateButton = ({ onGenerate, disabled, isLoading }) => {
+ const onUpgradeHover = () => {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeSuggestionViewed, {
+ current_plan: window.ea11yScannerData?.planData?.plan?.name,
+ component: 'bulk_wizard_single_image',
+ feature: 'bulk_alt_text',
+ });
+ };
+
+ return (
+
+ {IS_PRO_PLAN && AI_QUOTA_LIMIT ? (
+
+
+
+
+
+ ) : (
+ }
+ >
+
+
+
+
+ )}
+
+ );
+};
+
+AIGenerateButton.propTypes = {
+ onGenerate: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+};
+
+export default AIGenerateButton;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/alt-text-input.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/alt-text-input.js
new file mode 100644
index 00000000..3c7c80ae
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/alt-text-input.js
@@ -0,0 +1,80 @@
+import InfoCircleIcon from '@elementor/icons/InfoCircleIcon';
+import Chip from '@elementor/ui/Chip';
+import PropTypes from 'prop-types';
+import { __ } from '@wordpress/i18n';
+import AIGenerateButton from './ai-generate-button';
+import CardActionButtons from './card-action-buttons';
+import { StyledTextField } from './styled-components';
+
+const AltTextInput = ({
+ isDecorative,
+ altText,
+ isLoading,
+ isDraft,
+ onChange,
+ onGenerate,
+ onSave,
+ onCancel,
+}) => {
+ if (isDecorative) {
+ return (
+ }
+ variant="standard"
+ />
+ );
+ }
+
+ return (
+ <>
+
+ ),
+ }}
+ />
+ {isDraft && !isDecorative && (
+
+ )}
+ >
+ );
+};
+
+AltTextInput.propTypes = {
+ isDecorative: PropTypes.bool.isRequired,
+ altText: PropTypes.string,
+ isLoading: PropTypes.bool,
+ isDraft: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onGenerate: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+};
+
+export default AltTextInput;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-action-buttons.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-action-buttons.js
new file mode 100644
index 00000000..c89b2c81
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-action-buttons.js
@@ -0,0 +1,31 @@
+import Button from '@elementor/ui/Button';
+import Grid from '@elementor/ui/Grid';
+import PropTypes from 'prop-types';
+import { __ } from '@wordpress/i18n';
+
+const CardActionButtons = ({ onSave, onCancel, altText }) => {
+ return (
+
+
+ {__('Cancel', 'pojo-accessibility')}
+
+
+ {__('Save', 'pojo-accessibility')}
+
+
+ );
+};
+
+CardActionButtons.propTypes = {
+ onSave: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ altText: PropTypes.string,
+};
+
+export default CardActionButtons;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-selection-indicator.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-selection-indicator.js
new file mode 100644
index 00000000..f1296a39
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-selection-indicator.js
@@ -0,0 +1,58 @@
+import CircleCheckFilledIcon from '@elementor/icons/CircleCheckFilledIcon';
+import CircularProgress from '@elementor/ui/CircularProgress';
+import Radio from '@elementor/ui/Radio';
+import PropTypes from 'prop-types';
+import { __ } from '@wordpress/i18n';
+
+const CardSelectionIndicator = ({
+ isLoading,
+ isSelected,
+ hasValidAltText,
+ onRadioClick,
+}) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+ }
+ sx={{
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ color: 'action.disabled',
+ cursor: isSelected && hasValidAltText ? 'not-allowed' : 'pointer',
+ }}
+ color={isSelected && hasValidAltText ? 'success' : 'info'}
+ onClick={onRadioClick}
+ tabIndex={0}
+ aria-label={__(
+ 'Add image to bulk alt text editing',
+ 'pojo-accessibility',
+ )}
+ aria-disabled={isSelected && hasValidAltText}
+ />
+ );
+};
+
+CardSelectionIndicator.propTypes = {
+ imageLabel: PropTypes.string,
+ isLoading: PropTypes.bool,
+ isSelected: PropTypes.bool.isRequired,
+ hasValidAltText: PropTypes.bool,
+ onRadioClick: PropTypes.func.isRequired,
+};
+
+export default CardSelectionIndicator;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/decorative-checkbox.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/decorative-checkbox.js
new file mode 100644
index 00000000..59b3d4d5
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/decorative-checkbox.js
@@ -0,0 +1,27 @@
+import Checkbox from '@elementor/ui/Checkbox';
+import FormControlLabel from '@elementor/ui/FormControlLabel';
+import Typography from '@elementor/ui/Typography';
+import PropTypes from 'prop-types';
+import { __ } from '@wordpress/i18n';
+
+const DecorativeCheckbox = ({ checked, onChange }) => {
+ return (
+ }
+ label={
+
+ {__('Mark as decorative', 'pojo-accessibility')}
+
+ }
+ onChange={onChange}
+ sx={{ paddingInline: 1.7, paddingBlockEnd: 1.5 }}
+ />
+ );
+};
+
+DecorativeCheckbox.propTypes = {
+ checked: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default DecorativeCheckbox;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/index.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/index.js
new file mode 100644
index 00000000..61943110
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/index.js
@@ -0,0 +1,143 @@
+import CardContent from '@elementor/ui/CardContent';
+import Grid from '@elementor/ui/Grid';
+import PropTypes from 'prop-types';
+import { ImagePreview } from '@ea11y-apps/scanner/components/alt-text-form/image-preview';
+import { useAltTextForm } from '@ea11y-apps/scanner/hooks/use-alt-text-form';
+import { speak } from '@wordpress/a11y';
+import { memo } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import AltTextInput from './alt-text-input';
+import CardSelectionIndicator from './card-selection-indicator';
+import DecorativeCheckbox from './decorative-checkbox';
+import { StyledCard, StyledPreviewWrapper } from './styled-components';
+
+const getImageLabel = (node) => {
+ if (!node) {
+ return __('Image', 'pojo-accessibility');
+ }
+ if (node.src) {
+ try {
+ const pathname = new URL(node.src).pathname;
+ const encodedFilename = pathname.split('/').pop();
+ if (!encodedFilename) {
+ return __('Image', 'pojo-accessibility');
+ }
+ try {
+ return decodeURIComponent(encodedFilename);
+ } catch {
+ return encodedFilename;
+ }
+ } catch {
+ return __('Image', 'pojo-accessibility');
+ }
+ }
+ return __('Image', 'pojo-accessibility');
+};
+
+const ImageCard = ({ item, current }) => {
+ const {
+ data,
+ loadingAiText,
+ handleChange,
+ handleCheck,
+ handleSave,
+ handleCancel,
+ generateAltText,
+ updateData,
+ } = useAltTextForm({
+ current,
+ item,
+ });
+
+ const handleRadioClick = () => {
+ const hasValidAltText = data?.[current]?.hasValidAltText;
+ const isCurrentlySelected = data?.[current]?.selected;
+
+ if (isCurrentlySelected && hasValidAltText) {
+ return;
+ }
+
+ const willBeSelected = !isCurrentlySelected;
+ updateData({
+ selected: willBeSelected,
+ });
+
+ const message = willBeSelected
+ ? __('Image selected', 'pojo-accessibility')
+ : __('Image deselected', 'pojo-accessibility');
+ speak(message, 'polite');
+ };
+
+ const isLoading = loadingAiText || data?.[current]?.isGenerating;
+ const imageLabel = getImageLabel(item?.node);
+ const cardLabel = sprintf(
+ /* translators: %s: image file name or "Image" */
+ __('Alt text card for %s', 'pojo-accessibility'),
+ imageLabel,
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {!data?.[current]?.isDraft && (
+
+ )}
+
+
+ );
+};
+
+ImageCard.propTypes = {
+ item: PropTypes.object.isRequired,
+ current: PropTypes.number.isRequired,
+};
+
+export default memo(ImageCard);
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/styled-components.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/styled-components.js
new file mode 100644
index 00000000..c587907e
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/styled-components.js
@@ -0,0 +1,67 @@
+import Card from '@elementor/ui/Card';
+import Grid from '@elementor/ui/Grid';
+import TextField from '@elementor/ui/TextField';
+import { styled } from '@elementor/ui/styles';
+
+export const StyledCard = styled(Card, {
+ shouldForwardProp: (prop) =>
+ prop !== 'isLoading' &&
+ prop !== 'isCurrentlySelected' &&
+ prop !== 'hasValidAltText' &&
+ prop !== 'isDraft' &&
+ prop !== 'isDecorative',
+})`
+ border-radius: ${({ theme }) => theme.shape.borderRadius * 2}px;
+ height: 282px;
+ width: 248px;
+
+ & .MuiCardContent-root:last-child {
+ padding-bottom: 0;
+ }
+
+ ${({ theme, isLoading, isCurrentlySelected, hasValidAltText, isDraft }) =>
+ (isLoading || (isCurrentlySelected && !hasValidAltText)) && !isDraft
+ ? `border: 2px solid; border-color: ${theme.palette.info.main};`
+ : ''}
+`;
+
+export const StyledPreviewWrapper = styled(Grid)`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & .MuiPaper-root {
+ background-color: transparent;
+ width: 130px;
+ height: 96px;
+ }
+
+ & img {
+ width: 130px;
+ height: 96px;
+ font-size: auto;
+ object-fit: cover;
+ border-radius: ${({ theme }) => theme.shape.borderRadius * 2}px;
+ }
+`;
+
+export const StyledTextField = styled(TextField)`
+ & .MuiInputBase-root {
+ padding: 0;
+ font-size: 14px;
+ }
+
+ & .MuiInputBase-input {
+ padding: ${({ theme }) =>
+ `${theme.spacing(1)} ${theme.spacing(1.5)} ${theme.spacing(1)} ${theme.spacing(1)}`};
+ }
+
+ & fieldset {
+ border-color: transparent;
+ overflow: auto;
+
+ &:selected {
+ border: auto;
+ }
+ }
+`;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-grid.js b/modules/scanner/assets/js/components/bulk-alt-text/image-grid.js
new file mode 100644
index 00000000..b2346b3f
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/image-grid.js
@@ -0,0 +1,40 @@
+import Alert from '@elementor/ui/Alert';
+import AlertTitle from '@elementor/ui/AlertTitle';
+import ErrorBoundary from '@elementor/ui/ErrorBoundary';
+import Grid from '@elementor/ui/Grid';
+import ImageCard from '@ea11y-apps/scanner/components/bulk-alt-text/image-card';
+import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context';
+import { __ } from '@wordpress/i18n';
+
+const ImageCardErrorFallback = () => (
+
+ {__('Error', 'pojo-accessibility')}
+ {__(
+ 'This image card failed to load. Please refresh and try again.',
+ 'pojo-accessibility',
+ )}
+
+);
+
+const ImageGrid = () => {
+ const { sortedViolations } = useScannerWizardContext();
+ const altTextViolations = sortedViolations.altText;
+
+ return (
+
+ {altTextViolations.map((image, index) => {
+ const stableKey =
+ image.path?.dom || image.node?.src || `img-card-${index}`;
+ return (
+
+ }>
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default ImageGrid;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/default-progress.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/default-progress.js
new file mode 100644
index 00000000..13089320
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/default-progress.js
@@ -0,0 +1,73 @@
+import Button from '@elementor/ui/Button';
+import LinearProgress from '@elementor/ui/LinearProgress';
+import Typography from '@elementor/ui/Typography';
+import PropTypes from 'prop-types';
+import PencilTickIcon from '@ea11y-apps/scanner/icons/pencil-tick-icon';
+import PencilUndoIcon from '@ea11y-apps/scanner/icons/pencil-undo-icon';
+import { __ } from '@wordpress/i18n';
+import GenerateAllButton from './generate-all-button';
+import { StyledMainWrapperGrid, StyledActionsGrid } from './styled-components';
+
+const DefaultProgress = ({
+ completedSelectedCount,
+ totalImages,
+ areAllMarkedAsDecorative,
+ isGenerating,
+ onToggleAllDecorative,
+ onGenerateAll,
+ generateButtonText,
+}) => {
+ return (
+
+
+
+ {`${completedSelectedCount}/${totalImages}`}{' '}
+ {__('ready to apply', 'pojo-accessibility')}
+
+
+ 0 ? (completedSelectedCount / totalImages) * 100 : 0
+ }
+ variant="determinate"
+ color={completedSelectedCount > 0 ? 'success' : 'secondary'}
+ sx={{ flexGrow: 1, marginInlineEnd: 5 }}
+ />
+
+
+ ) : (
+
+ )
+ }
+ onClick={onToggleAllDecorative}
+ >
+ {areAllMarkedAsDecorative
+ ? __('Undo Decorative', 'pojo-accessibility')
+ : __('Mark all as decorative', 'pojo-accessibility')}
+
+
+
+
+ );
+};
+
+DefaultProgress.propTypes = {
+ completedSelectedCount: PropTypes.number.isRequired,
+ totalImages: PropTypes.number.isRequired,
+ areAllMarkedAsDecorative: PropTypes.bool.isRequired,
+ isGenerating: PropTypes.bool.isRequired,
+ onToggleAllDecorative: PropTypes.func.isRequired,
+ onGenerateAll: PropTypes.func.isRequired,
+ generateButtonText: PropTypes.string.isRequired,
+};
+
+export default DefaultProgress;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generate-all-button.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generate-all-button.js
new file mode 100644
index 00000000..7d4b9a39
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generate-all-button.js
@@ -0,0 +1,63 @@
+import AIIcon from '@elementor/icons/AIIcon';
+import Button from '@elementor/ui/Button';
+import Infotip from '@elementor/ui/Infotip';
+import PropTypes from 'prop-types';
+import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
+import { UpgradeContent } from '@ea11y-apps/scanner/components/upgrade-info-tip/upgrade-content';
+import { AI_QUOTA_LIMIT, IS_PRO_PLAN } from '@ea11y-apps/scanner/constants';
+
+const GenerateAllButton = ({ onClick, disabled, text }) => {
+ const onUpgradeHover = () => {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeTooltipTriggered, {
+ feature: 'bulk_alt_text',
+ component: 'bulk_wizard_main_button',
+ });
+ };
+
+ if (IS_PRO_PLAN && AI_QUOTA_LIMIT) {
+ return (
+ }
+ onClick={onClick}
+ disabled={disabled}
+ >
+ {text}
+
+ );
+ }
+
+ return (
+ }
+ >
+ }
+ aria-labelledby="generate-all-btn-upgrade"
+ onMouseEnter={onUpgradeHover}
+ >
+ {text}
+
+
+ );
+};
+
+GenerateAllButton.propTypes = {
+ onClick: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
+ text: PropTypes.string.isRequired,
+};
+
+export default GenerateAllButton;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generating-progress.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generating-progress.js
new file mode 100644
index 00000000..703b01d9
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generating-progress.js
@@ -0,0 +1,74 @@
+import Alert from '@elementor/ui/Alert';
+import AlertTitle from '@elementor/ui/AlertTitle';
+import Box from '@elementor/ui/Box';
+import Button from '@elementor/ui/Button';
+import LinearProgress from '@elementor/ui/LinearProgress';
+import Typography from '@elementor/ui/Typography';
+import PropTypes from 'prop-types';
+import PlayerStopIcon from '@ea11y-apps/scanner/icons/player-stop-icon';
+import { __, sprintf } from '@wordpress/i18n';
+
+const GeneratingProgress = ({ progress, onStop }) => {
+ const progressText = sprintf(
+ // Translators: %1$d current count, %2$d total count
+ __('%1$d/%2$d completed', 'pojo-accessibility'),
+ progress.completed,
+ progress.total,
+ );
+
+ return (
+
+ 0 ? (progress.completed / progress.total) * 100 : 0
+ }
+ variant="determinate"
+ color="info"
+ sx={{ flexGrow: 1 }}
+ aria-label={__('Generation progress', 'pojo-accessibility')}
+ />
+
+ {progressText}
+
+ }
+ action={
+ }
+ >
+ {__('Stop', 'pojo-accessibility')}
+
+ }
+ >
+
+ {__('Generating alt text for images…', 'pojo-accessibility')}
+
+
+
+ );
+};
+
+GeneratingProgress.propTypes = {
+ progress: PropTypes.shape({
+ completed: PropTypes.number.isRequired,
+ total: PropTypes.number.isRequired,
+ }).isRequired,
+ onStop: PropTypes.func.isRequired,
+};
+
+export default GeneratingProgress;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/index.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/index.js
new file mode 100644
index 00000000..43feacb3
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/index.js
@@ -0,0 +1 @@
+export { default } from './progress-bar-container';
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/progress-bar-container.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/progress-bar-container.js
new file mode 100644
index 00000000..546ff48b
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/progress-bar-container.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import { useProgressBarLogic } from '@ea11y-apps/scanner/hooks/use-progress-bar-logic';
+import DefaultProgress from './default-progress';
+import GeneratingProgress from './generating-progress';
+import SelectionModeProgress from './selection-mode-progress';
+
+const ProgressBarContainer = ({ onGeneratingChange }) => {
+ const {
+ isGenerating,
+ progress,
+ showManualSelectionMode,
+ manuallySelectedCount,
+ completedSelectedCount,
+ totalImages,
+ areAllMarkedAsDecorative,
+ handleStopGenerating,
+ handleClearSelection,
+ handleMarkSelectedAsDecorative,
+ handleToggleAllDecorative,
+ handleGenerateAll,
+ generateButtonText,
+ } = useProgressBarLogic(onGeneratingChange);
+
+ if (isGenerating) {
+ return (
+
+ );
+ }
+
+ if (showManualSelectionMode) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+ProgressBarContainer.propTypes = {
+ onGeneratingChange: PropTypes.func,
+};
+
+export default ProgressBarContainer;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/selection-mode-progress.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/selection-mode-progress.js
new file mode 100644
index 00000000..b0d78372
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/selection-mode-progress.js
@@ -0,0 +1,61 @@
+import Button from '@elementor/ui/Button';
+import Typography from '@elementor/ui/Typography';
+import PropTypes from 'prop-types';
+import PencilTickIcon from '@ea11y-apps/scanner/icons/pencil-tick-icon';
+import { __, sprintf } from '@wordpress/i18n';
+import GenerateAllButton from './generate-all-button';
+import { StyledMainWrapperGrid, StyledActionsGrid } from './styled-components';
+
+const SelectionModeProgress = ({
+ manuallySelectedCount,
+ isGenerating,
+ onClear,
+ onMarkAsDecorative,
+ onGenerate,
+}) => {
+ return (
+
+
+
+ {sprintf(
+ // Translators: %d number of selected images
+ __('%d selected', 'pojo-accessibility'),
+ manuallySelectedCount,
+ )}
+
+
+ {__('Clear', 'pojo-accessibility')}
+
+
+
+ }
+ onClick={onMarkAsDecorative}
+ >
+ {__('Mark as decorative', 'pojo-accessibility')}
+
+
+
+
+ );
+};
+
+SelectionModeProgress.propTypes = {
+ manuallySelectedCount: PropTypes.number.isRequired,
+ isGenerating: PropTypes.bool.isRequired,
+ onClear: PropTypes.func.isRequired,
+ onMarkAsDecorative: PropTypes.func.isRequired,
+ onGenerate: PropTypes.func.isRequired,
+};
+
+export default SelectionModeProgress;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/styled-components.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/styled-components.js
new file mode 100644
index 00000000..e6f98138
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/styled-components.js
@@ -0,0 +1,18 @@
+import Grid from '@elementor/ui/Grid';
+import { styled } from '@elementor/ui/styles';
+
+export const StyledMainWrapperGrid = styled(Grid)`
+ padding: 16px;
+ gap: 8px;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+`;
+
+export const StyledActionsGrid = styled(Grid)`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: nowrap;
+`;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/quota-error-alert.js b/modules/scanner/assets/js/components/bulk-alt-text/quota-error-alert.js
new file mode 100644
index 00000000..4c41c4cf
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/quota-error-alert.js
@@ -0,0 +1,57 @@
+import Alert from '@elementor/ui/Alert';
+import AlertTitle from '@elementor/ui/AlertTitle';
+import Button from '@elementor/ui/Button';
+import { COMPARE_PLAN_URL } from '@ea11y-apps/scanner/constants';
+import { useBulkGeneration } from '@ea11y-apps/scanner/context/bulk-generation-context';
+import { useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+const QuotaErrorAlert = () => {
+ const { quotaError, progress } = useBulkGeneration();
+ const [isDismissed, setIsDismissed] = useState(false);
+
+ if (!quotaError || isDismissed) {
+ return null;
+ }
+
+ const generatedCount = progress?.completed || 0;
+ const totalCount = progress?.total || 0;
+
+ return (
+ setIsDismissed(true)}
+ action={
+
+ {__('Compare plans', 'pojo-accessibility')}
+
+ }
+ >
+
+ {__('Some images could not be generated.', 'pojo-accessibility')}
+
+ {sprintf(
+ // Translators: %1$d generated count, %2$d total count
+ __(
+ 'We generated %1$d/%2$d images before credits ran out — upgrade your plan or wait until next month.',
+ 'pojo-accessibility',
+ ),
+ generatedCount,
+ totalCount,
+ )}
+
+ );
+};
+
+export default QuotaErrorAlert;
diff --git a/modules/scanner/assets/js/components/bulk-alt-text/upgrade-infotip.js b/modules/scanner/assets/js/components/bulk-alt-text/upgrade-infotip.js
new file mode 100644
index 00000000..498d5b64
--- /dev/null
+++ b/modules/scanner/assets/js/components/bulk-alt-text/upgrade-infotip.js
@@ -0,0 +1,60 @@
+import Button from '@elementor/ui/Button';
+import Card from '@elementor/ui/Card';
+import CardActions from '@elementor/ui/CardActions';
+import CardContent from '@elementor/ui/CardContent';
+import CardHeader from '@elementor/ui/CardHeader';
+import Typography from '@elementor/ui/Typography';
+import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events';
+import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service';
+import { BULK_UPGRADE_URL } from '@ea11y-apps/scanner/constants';
+import { useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+const UpgradeInfotip = ({ trigger, action }) => {
+ useEffect(() => {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeTooltipTriggered, {
+ feature: trigger?.feature,
+ component: trigger?.component,
+ });
+ }, []);
+
+ const onUpgrade = () => {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, {
+ current_plan: window.ea11yScannerData?.planData?.plan?.name,
+ feature: action?.feature,
+ component: action?.component,
+ });
+ };
+ return (
+
+
+
+
+ {__(
+ 'Upgrade to handle alt text in bulk: generate or mark as decorative, in one click.',
+ 'pojo-accessibility',
+ )}
+
+
+
+
+ {__('Upgrade now', 'pojo-accessibility')}
+
+
+
+ );
+};
+
+export default UpgradeInfotip;
diff --git a/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js b/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js
index 22828d01..c3256d59 100644
--- a/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js
+++ b/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js
@@ -10,21 +10,55 @@ import {
COMPARE_PLAN_URL,
IS_PRO_PLAN,
UPGRADE_URL,
+ BULK_UPGRADE_URL,
} from '@ea11y-apps/scanner/constants';
import { UpgradeContentContainer } from '@ea11y-apps/scanner/styles/app.styles';
import {
InfotipBox,
InfotipFooter,
} from '@ea11y-apps/scanner/styles/manual-fixes.styles';
+import { useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
-export const UpgradeContent = ({ closeUpgrade, isAlt = false }) => {
- const onUpgrade = () => {
- mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, {
+export const UpgradeContent = ({
+ closeUpgrade,
+ isAlt = false,
+ isBulkAlt = false,
+ isBulkSingleImage = false,
+}) => {
+ useEffect(() => {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeTooltipTriggered, {
current_plan: window.ea11yScannerData?.planData?.plan?.name,
- action_trigger: 'ai_suggestion_accepted',
- feature_locked: isAlt ? 'AI alt-text' : 'AI manual',
+ component: 'button_wizard_main_button',
+ feature: 'bulk_alt_text',
});
+ }, [isBulkAlt]);
+ const onUpgrade = () => {
+ if (isBulkAlt) {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, {
+ current_plan: window.ea11yScannerData?.planData?.plan?.name,
+ component: 'button',
+ feature: isBulkSingleImage
+ ? 'bulk_wizard_single_image'
+ : 'bulk_wizard_main_cta',
+ });
+ } else {
+ mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, {
+ current_plan: window.ea11yScannerData?.planData?.plan?.name,
+ action_trigger: 'ai_suggestionf_accepted',
+ feature_locked: isAlt ? 'AI alt-text' : 'AI manual',
+ });
+ }
+ };
+
+ const getURL = () => {
+ if (isBulkAlt) {
+ return getUpgradeLink(BULK_UPGRADE_URL);
+ }
+ if (IS_PRO_PLAN) {
+ return getUpgradeLink(COMPARE_PLAN_URL);
+ }
+ return getUpgradeLink(UPGRADE_URL);
};
return (
@@ -62,7 +96,7 @@ export const UpgradeContent = ({ closeUpgrade, isAlt = false }) => {
size="small"
color="promotion"
variant="contained"
- href={IS_PRO_PLAN ? COMPARE_PLAN_URL : getUpgradeLink(UPGRADE_URL)}
+ href={getURL()}
target="_blank"
rel="noreferrer"
startIcon={!IS_PRO_PLAN ? : null}
diff --git a/modules/scanner/assets/js/constants/index.js b/modules/scanner/assets/js/constants/index.js
index 08bc81a8..1ec0d60e 100644
--- a/modules/scanner/assets/js/constants/index.js
+++ b/modules/scanner/assets/js/constants/index.js
@@ -23,6 +23,7 @@ export const RATIO_EXCLUDED = 1;
export const UPGRADE_URL = 'https://go.elementor.com/acc-free-no-AI-scanner';
export const UPGRADE_GLOBAL_URL =
'https://go.elementor.com/acc-global-remediation';
+export const BULK_UPGRADE_URL = 'https://go.elementor.com/acc-bulk-alt-text';
export const COMPARE_PLAN_URL = 'https://go.elementor.com/acc-AI-limit-scanner';
export const PAGE_LIMIT_URL = 'https://go.elementor.com/acc-URL-limit-scanner';
diff --git a/modules/scanner/assets/js/context/bulk-generation-context.js b/modules/scanner/assets/js/context/bulk-generation-context.js
new file mode 100644
index 00000000..cbec9417
--- /dev/null
+++ b/modules/scanner/assets/js/context/bulk-generation-context.js
@@ -0,0 +1,143 @@
+import PropTypes from 'prop-types';
+import { speak } from '@wordpress/a11y';
+import {
+ createContext,
+ useContext,
+ useState,
+ useRef,
+ useCallback,
+} from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+const BulkGenerationContext = createContext(null);
+
+export const BulkGenerationProvider = ({ children }) => {
+ const [currentGeneratingIndex, setCurrentGeneratingIndex] = useState(null);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [quotaError, setQuotaError] = useState(null);
+ const shouldAbortRef = useRef(false);
+ const queueRef = useRef([]);
+ const progressRef = useRef({ completed: 0, total: 0, errors: 0 });
+ const [progress, setProgress] = useState({
+ completed: 0,
+ total: 0,
+ errors: 0,
+ });
+
+ const startBulkGeneration = useCallback((cardIndices) => {
+ shouldAbortRef.current = false;
+ queueRef.current = [...cardIndices];
+ progressRef.current = {
+ completed: 0,
+ total: cardIndices.length,
+ errors: 0,
+ };
+ setProgress({ completed: 0, total: cardIndices.length, errors: 0 });
+ setQuotaError(null);
+ setIsGenerating(true);
+
+ if (cardIndices.length > 0) {
+ setCurrentGeneratingIndex(cardIndices[0]);
+ } else {
+ setIsGenerating(false);
+ }
+ }, []);
+
+ const stopBulkGeneration = useCallback(() => {
+ shouldAbortRef.current = true;
+ queueRef.current = [];
+ setCurrentGeneratingIndex(null);
+ setIsGenerating(false);
+ }, []);
+
+ const onCardComplete = useCallback((success) => {
+ progressRef.current.completed += 1;
+ if (!success) {
+ progressRef.current.errors += 1;
+ }
+ setProgress({ ...progressRef.current });
+
+ queueRef.current.shift();
+
+ if (shouldAbortRef.current || queueRef.current.length === 0) {
+ setCurrentGeneratingIndex(null);
+ setIsGenerating(false);
+
+ // Announce completion
+ if (queueRef.current.length === 0) {
+ const { completed, errors } = progressRef.current;
+ if (errors > 0) {
+ const message = sprintf(
+ // Translators: %1$d successful count, %2$d failed count
+ __(
+ 'Generation complete. %1$d succeeded, %2$d failed',
+ 'pojo-accessibility',
+ ),
+ completed - errors,
+ errors,
+ );
+ speak(message, 'assertive');
+ } else {
+ const message = sprintf(
+ // Translators: %d number of images
+ __(
+ 'Generation complete. %d images processed successfully',
+ 'pojo-accessibility',
+ ),
+ completed,
+ );
+ speak(message, 'assertive');
+ }
+ }
+ } else {
+ setCurrentGeneratingIndex(queueRef.current[0]);
+ }
+ }, []);
+
+ const resetProgress = useCallback(() => {
+ setIsGenerating(false);
+ shouldAbortRef.current = false;
+ queueRef.current = [];
+ setCurrentGeneratingIndex(null);
+ progressRef.current = { completed: 0, total: 0, errors: 0 };
+ setProgress({ completed: 0, total: 0, errors: 0 });
+ setQuotaError(null);
+ }, []);
+
+ const setQuotaExceeded = useCallback((errorMessage) => {
+ setQuotaError(errorMessage);
+ shouldAbortRef.current = true;
+ queueRef.current = [];
+ setCurrentGeneratingIndex(null);
+ setIsGenerating(false);
+ speak(errorMessage, 'assertive');
+ }, []);
+
+ const value = {
+ currentGeneratingIndex,
+ isGenerating,
+ shouldAbort: shouldAbortRef,
+ progress,
+ quotaError,
+ startBulkGeneration,
+ stopBulkGeneration,
+ onCardComplete,
+ resetProgress,
+ setQuotaExceeded,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+BulkGenerationProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export const useBulkGeneration = () => {
+ const context = useContext(BulkGenerationContext);
+ return context;
+};
diff --git a/modules/scanner/assets/js/hooks/use-alt-text-form.js b/modules/scanner/assets/js/hooks/use-alt-text-form.js
index 0e220aa0..bcdc34ab 100644
--- a/modules/scanner/assets/js/hooks/use-alt-text-form.js
+++ b/modules/scanner/assets/js/hooks/use-alt-text-form.js
@@ -3,6 +3,7 @@ import { useToastNotification } from '@ea11y-apps/global/hooks';
import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner';
import { BLOCKS } from '@ea11y-apps/scanner/constants';
+import { useBulkGeneration } from '@ea11y-apps/scanner/context/bulk-generation-context';
import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context';
import { scannerItem } from '@ea11y-apps/scanner/types/scanner-item';
import { removeExistingFocus } from '@ea11y-apps/scanner/utils/focus-on-element';
@@ -12,9 +13,36 @@ import {
convertSvgToPngBase64,
svgNodeToPngBase64,
} from '@ea11y-apps/scanner/utils/svg-to-png-base64';
+import { speak } from '@wordpress/a11y';
import { useEffect, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
+export const getPayload = async (item) => {
+ if (item.node?.src) {
+ return item.node.src.toLowerCase().endsWith('.svg')
+ ? { svg: await convertSvgToPngBase64(item.node.src) }
+ : { image: item.node.src };
+ }
+ return { svg: await svgNodeToPngBase64(item.node) };
+};
+
+export const generateAiAltText = async (item, signal = null) => {
+ const data = await getPayload(item);
+ const result = await APIScanner.generateAltText(data, signal);
+ const descriptions = splitDescriptions(result.data.response);
+
+ if (!descriptions[0]) {
+ throw new Error('No description generated');
+ }
+
+ return {
+ altText: descriptions[0],
+ aiText: descriptions,
+ apiId: result.data.apiId,
+ aiTextIndex: 0,
+ };
+};
+
export const useAltTextForm = ({ current, item }) => {
const {
altTextData,
@@ -29,6 +57,12 @@ export const useAltTextForm = ({ current, item }) => {
setIsManageChanged,
} = useScannerWizardContext();
const { error } = useToastNotification();
+ const bulkGeneration = useBulkGeneration();
+
+ const currentGeneratingIndex = bulkGeneration?.currentGeneratingIndex;
+ const shouldAbort = bulkGeneration?.shouldAbort || { current: false };
+ const onCardComplete = bulkGeneration?.onCardComplete || (() => {});
+ const setQuotaExceeded = bulkGeneration?.setQuotaExceeded || (() => {});
const [loadingAiText, setLoadingAiText] = useState(false);
const [loading, setLoading] = useState(false);
@@ -39,14 +73,35 @@ export const useAltTextForm = ({ current, item }) => {
altTextData?.[type]?.[current]?.isGlobal || item.global || false;
const isGlobalRef = useRef(null);
+ const savedAltTextRef = useRef('');
+ const abortControllerRef = useRef(null);
+ const hasInitializedRef = useRef(false);
useEffect(() => {
if (item?.node) {
isGlobalRef.current = isGlobal;
+ if (altTextData?.[type]?.[current]?.hasValidAltText) {
+ savedAltTextRef.current = altTextData?.[type]?.[current]?.altText || '';
+ }
}
}, [current]);
useEffect(() => {
+ if (hasInitializedRef.current === current) {
+ return;
+ }
+
+ const hasExistingData =
+ altTextData?.[type]?.[current]?.hasValidAltText ||
+ altTextData?.[type]?.[current]?.altText ||
+ altTextData?.[type]?.[current]?.isGenerating ||
+ altTextData?.[type]?.[current]?.makeDecorative !== undefined;
+
+ if (hasExistingData) {
+ hasInitializedRef.current = current;
+ return;
+ }
+
if (isManage) {
updateData({
makeDecorative: item.data.attribute_name === 'role',
@@ -57,6 +112,8 @@ export const useAltTextForm = ({ current, item }) => {
} else {
updateData({ isGlobal });
}
+
+ hasInitializedRef.current = current;
}, [isManage, current]);
useEffect(() => {
@@ -67,6 +124,97 @@ export const useAltTextForm = ({ current, item }) => {
setFirstOpen(false);
}, [isResolved(BLOCKS.altText)]);
+ useEffect(() => {
+ const isMyTurn = currentGeneratingIndex === current;
+
+ if (!isMyTurn) {
+ return;
+ }
+
+ const itemData = altTextData?.[type]?.[current];
+ const hasValidAlt = itemData?.hasValidAltText;
+ const isDecorative = itemData?.makeDecorative;
+
+ const shouldGenerate = !hasValidAlt && !isDecorative;
+
+ if (shouldGenerate) {
+ const generateForCard = async () => {
+ if (shouldAbort.current) {
+ return;
+ }
+
+ updateData({ isGenerating: true });
+ abortControllerRef.current = new AbortController();
+
+ try {
+ const aiData = await generateAiAltText(
+ item,
+ abortControllerRef.current.signal,
+ );
+
+ if (shouldAbort.current) {
+ updateData({ isGenerating: false });
+ return;
+ }
+
+ updateData({
+ ...aiData,
+ selected: true,
+ resolved: false,
+ hasValidAltText: true,
+ isDraft: false,
+ isGenerating: false,
+ });
+
+ speak(
+ __('Alt text generated successfully', 'pojo-accessibility'),
+ 'polite',
+ );
+ sendMixpanelEvent(aiData.altText);
+ onCardComplete(true);
+ } catch (e) {
+ updateData({ isGenerating: false });
+
+ if (!shouldAbort.current) {
+ if (e?.code === 'quota_exceeded') {
+ const errorMessage =
+ e?.message ||
+ __(
+ 'AI credits quota has been exceeded. Please upgrade your plan or wait for the next billing cycle.',
+ 'pojo-accessibility',
+ );
+ speak(errorMessage, 'assertive');
+ setQuotaExceeded(errorMessage);
+ return; // Don't call onCardComplete
+ }
+
+ if (e?.code === 'quota_api_error') {
+ const errorMessage =
+ e?.message ||
+ __(
+ 'There was an error in generating Alt text using AI. Please try again after sometime.',
+ 'pojo-accessibility',
+ );
+ speak(errorMessage, 'assertive');
+ return; // Don't call onCardComplete
+ }
+
+ console.error(`Failed to generate AI text for card ${current}:`, e);
+ onCardComplete(false);
+ }
+ }
+ };
+
+ generateForCard();
+ }
+
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ };
+ }, [currentGeneratingIndex]);
+
const setIsGlobal = (value) => {
updateData({
isGlobal: value,
@@ -74,17 +222,19 @@ export const useAltTextForm = ({ current, item }) => {
};
const updateData = (data) => {
- const updData = [...altTextData?.[type]];
- if (altTextData?.[type]?.[current]?.resolved && !data.resolved) {
- setResolved(resolved - 1);
- }
- updData[current] = {
- ...(altTextData?.[type]?.[current] || {}),
- ...data,
- };
- setAltTextData({
- ...altTextData,
- [type]: updData,
+ setAltTextData((prevAltTextData) => {
+ const updData = [...(prevAltTextData?.[type] || [])];
+ if (prevAltTextData?.[type]?.[current]?.resolved && !data.resolved) {
+ setResolved((prev) => prev - 1);
+ }
+ updData[current] = {
+ ...(prevAltTextData?.[type]?.[current] || {}),
+ ...data,
+ };
+ return {
+ ...prevAltTextData,
+ [type]: updData,
+ };
});
};
@@ -145,24 +295,100 @@ export const useAltTextForm = ({ current, item }) => {
};
const handleCheck = (e) => {
- updateData({
- makeDecorative: e.target.checked,
- apiId: null,
- resolved: false,
- });
- if (e.target.checked) {
+ const isChecking = e.target.checked;
+ const currentAltText = altTextData?.[type]?.[current]?.altText?.trim();
+
+ // Calculate the number of decorative images after this action
+ const currentDecorativeCount = (altTextData?.[type] || []).filter(
+ (itm) => itm?.makeDecorative === true,
+ ).length;
+ const numOfImages = isChecking
+ ? currentDecorativeCount + 1
+ : Math.max(0, currentDecorativeCount - 1);
+
+ // Determine if we're in bulk mode
+ const isBulkMode =
+ currentGeneratingIndex !== null && currentGeneratingIndex !== undefined;
+
+ if (isChecking) {
+ updateData({
+ makeDecorative: true,
+ apiId: null,
+ resolved: false,
+ hasValidAltText: true,
+ isDraft: false,
+ selected: true,
+ });
+ speak(__('Image marked as decorative', 'pojo-accessibility'), 'polite');
+ mixpanelService.sendEvent(mixpanelEvents.markAsDecorativeSelected, {
+ category_name: BLOCKS.altText,
+ type: isBulkMode ? 'bulk' : 'single',
+ num_of_images: numOfImages,
+ action_type: 'mark',
+ });
+ } else {
+ const hasAltText = !!currentAltText;
+ updateData({
+ makeDecorative: false,
+ apiId: null,
+ resolved: false,
+ hasValidAltText: hasAltText,
+ isDraft: false,
+ selected: hasAltText,
+ });
+ speak(__('Image unmarked as decorative', 'pojo-accessibility'), 'polite');
mixpanelService.sendEvent(mixpanelEvents.markAsDecorativeSelected, {
category_name: BLOCKS.altText,
+ type: isBulkMode ? 'bulk' : 'single',
+ num_of_images: numOfImages,
+ action_type: 'undo',
});
}
};
const handleChange = (e) => {
+ const wasValidBefore = altTextData?.[type]?.[current]?.hasValidAltText;
+
+ if (!altTextData?.[type]?.[current]?.isDraft) {
+ savedAltTextRef.current = altTextData?.[type]?.[current]?.altText || '';
+ }
+
updateData({
altText: e.target.value,
apiId: null,
resolved: false,
+ isDraft: true,
+ hasValidAltText: false,
+ selected: wasValidBefore
+ ? false
+ : altTextData?.[type]?.[current]?.selected,
+ });
+ };
+
+ const handleSave = () => {
+ const altText = altTextData?.[type]?.[current]?.altText?.trim();
+ if (altText) {
+ savedAltTextRef.current = altText;
+ updateData({
+ hasValidAltText: true,
+ isDraft: false,
+ selected: true,
+ });
+ speak(__('Alt text saved', 'pojo-accessibility'), 'polite');
+ }
+ };
+
+ const handleCancel = () => {
+ const restoredHasValidAlt = !!savedAltTextRef.current;
+ updateData({
+ altText: savedAltTextRef.current,
+ isDraft: false,
+ hasValidAltText: restoredHasValidAlt,
+ selected: restoredHasValidAlt
+ ? true
+ : altTextData?.[type]?.[current]?.selected,
});
+ speak(__('Changes discarded', 'pojo-accessibility'), 'polite');
};
const handleSubmit = async () => {
@@ -239,16 +465,9 @@ export const useAltTextForm = ({ current, item }) => {
}
};
- const getPayload = async () => {
- if (item.node?.src) {
- return item.node.src.toLowerCase().endsWith('.svg')
- ? { svg: await convertSvgToPngBase64(item.node.src) }
- : { image: item.node.src };
- }
- return { svg: await svgNodeToPngBase64(item.node) };
- };
-
const sendMixpanelEvent = (text) => {
+ const isBulkMode =
+ currentGeneratingIndex !== null && currentGeneratingIndex !== undefined;
mixpanelService.sendEvent(mixpanelEvents.fixWithAiButtonClicked, {
issue_type: item.message,
rule_id: item.ruleId,
@@ -256,28 +475,51 @@ export const useAltTextForm = ({ current, item }) => {
category_name: BLOCKS.altText,
ai_text_response: text,
page_url: window.ea11yScannerData?.pageData?.url,
+ type: isBulkMode ? 'bulk' : 'single',
});
};
const getAiText = async () => {
setLoadingAiText(true);
- const data = await getPayload();
try {
- const result = await APIScanner.generateAltText(data);
- const descriptions = splitDescriptions(result.data.response);
- if (descriptions[0]) {
- updateData({
- altText: descriptions[0],
- aiText: descriptions,
- apiId: result.data.apiId,
- aiTextIndex: 0,
- resolved: false,
- });
- sendMixpanelEvent(descriptions[0]);
- }
+ const aiData = await generateAiAltText(item);
+ updateData({
+ ...aiData,
+ resolved: false,
+ hasValidAltText: true,
+ isDraft: false,
+ selected: true,
+ });
+ speak(
+ __('Alt text generated successfully', 'pojo-accessibility'),
+ 'polite',
+ );
+ sendMixpanelEvent(aiData.altText);
} catch (e) {
console.log(e);
- error(__('An error occurred.', 'pojo-accessibility'));
+
+ let errorMessage;
+
+ if (e?.code === 'quota_exceeded') {
+ errorMessage =
+ e?.message ||
+ __(
+ 'AI credits quota has been exceeded. Please upgrade your plan or wait for the next billing cycle.',
+ 'pojo-accessibility',
+ );
+ } else if (e?.code === 'quota_api_error') {
+ errorMessage =
+ e?.message ||
+ __(
+ 'Quota API error. Try again after sometime.',
+ 'pojo-accessibility',
+ );
+ } else {
+ errorMessage = __('An error occurred.', 'pojo-accessibility');
+ }
+
+ error(errorMessage);
+ speak(errorMessage, 'assertive');
} finally {
setLoadingAiText(false);
}
@@ -297,6 +539,10 @@ export const useAltTextForm = ({ current, item }) => {
resolved: false,
});
+ speak(
+ __('Alternative suggestion loaded', 'pojo-accessibility'),
+ 'polite',
+ );
sendMixpanelEvent(altTextData?.[type]?.[current]?.aiText[index]);
} else {
await getAiText();
@@ -324,9 +570,12 @@ export const useAltTextForm = ({ current, item }) => {
loading,
handleCheck,
handleChange,
+ handleSave,
+ handleCancel,
handleSubmit,
handleUpdate,
generateAltText,
+ updateData,
};
};
@@ -334,3 +583,66 @@ useAltTextForm.propTypes = {
current: PropTypes.number.isRequired,
item: scannerItem.isRequired,
};
+
+export const submitAltTextRemediation = async ({
+ item,
+ altText,
+ makeDecorative,
+ isGlobal,
+ apiId,
+ currentScanId,
+ updateRemediationList,
+}) => {
+ const makeAttributeData = () => {
+ if (makeDecorative) {
+ return {
+ attribute_name: 'role',
+ attribute_value: 'presentation',
+ };
+ }
+
+ if (item.node.tagName === 'svg') {
+ return {
+ attribute_name: 'aria-label',
+ attribute_value: altText,
+ };
+ }
+
+ return {
+ attribute_name: 'alt',
+ attribute_value: altText,
+ };
+ };
+
+ const match = item.node.className.toString().match(/wp-image-(\d+)/);
+ const finalAltText = !makeDecorative ? altText : '';
+ const find = item.snippet;
+
+ try {
+ if (match && item.node.tagName !== 'svg') {
+ void APIScanner.submitAltText(item.node.src, finalAltText);
+ }
+ const response = await APIScanner.submitRemediation({
+ url: window?.ea11yScannerData?.pageData.url,
+ remediation: {
+ ...makeAttributeData(),
+ action: 'add',
+ xpath: item.path.dom,
+ find,
+ category: item.reasonCategory.match(/\((AAA?|AA?|A)\)/)?.[1] || '',
+ type: 'ATTRIBUTE',
+ },
+ global: isGlobal,
+ rule: item.ruleId,
+ group: BLOCKS.altText,
+ apiId,
+ });
+
+ await APIScanner.resolveIssue(currentScanId);
+ void updateRemediationList();
+ return response.remediation;
+ } catch (e) {
+ console.warn(e);
+ throw e;
+ }
+};
diff --git a/modules/scanner/assets/js/hooks/use-prevent-aria-hidden.js b/modules/scanner/assets/js/hooks/use-prevent-aria-hidden.js
new file mode 100644
index 00000000..5ff3062a
--- /dev/null
+++ b/modules/scanner/assets/js/hooks/use-prevent-aria-hidden.js
@@ -0,0 +1,79 @@
+import { useLayoutEffect, useRef } from '@wordpress/element';
+
+/**
+ * Prevents MUI's ModalManager from adding aria-hidden="true" to document.body
+ * children. Inside a shadow root the ModalManager can't associate the modal
+ * with its shadow host, so it inadvertently hides the shadow host (and
+ * therefore the dialog itself) from screen readers.
+ *
+ * MUI sets aria-hidden via a ref callback during React's commit phase, before
+ * any effects run. We capture a snapshot of pre-existing aria-hidden state
+ * during render (before commit), then use useLayoutEffect to clean up what MUI
+ * added and attach an observer for any future mutations.
+ *
+ * MUI issue: https://github.com/mui/material-ui/issues/19450
+ *
+ * @param {boolean} active
+ */
+const usePreventAriaHidden = (active) => {
+ const snapshot = useRef(null);
+
+ // Capture which body children already have aria-hidden during the render
+ // phase, before React's commit phase where MUI's ref callbacks fire.
+ if (active && snapshot.current === null) {
+ const preserved = new Set();
+
+ for (const child of document.body.children) {
+ if (child.getAttribute('aria-hidden') !== null) {
+ preserved.add(child);
+ }
+ }
+ snapshot.current = preserved;
+ }
+
+ if (!active) {
+ snapshot.current = null;
+ }
+
+ useLayoutEffect(() => {
+ if (!active) {
+ return;
+ }
+
+ const preserved = snapshot.current || new Set();
+
+ const strip = (element) => {
+ if (
+ !preserved.has(element) &&
+ element.getAttribute('aria-hidden') === 'true'
+ ) {
+ element.removeAttribute('aria-hidden');
+ }
+ };
+
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.attributeName === 'aria-hidden') {
+ strip(mutation.target);
+ }
+ }
+ });
+
+ for (const child of document.body.children) {
+ observer.observe(child, {
+ attributes: true,
+ attributeFilter: ['aria-hidden'],
+ });
+
+ // Clean up aria-hidden that MUI already set via ref callbacks
+ // during the commit phase (before this layout effect ran).
+ strip(child);
+ }
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [active]);
+};
+
+export default usePreventAriaHidden;
diff --git a/modules/scanner/assets/js/hooks/use-progress-bar-logic.js b/modules/scanner/assets/js/hooks/use-progress-bar-logic.js
new file mode 100644
index 00000000..a1b02c0b
--- /dev/null
+++ b/modules/scanner/assets/js/hooks/use-progress-bar-logic.js
@@ -0,0 +1,248 @@
+import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events';
+import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service';
+import { useBulkGeneration } from '@ea11y-apps/scanner/context/bulk-generation-context';
+import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context';
+import { speak } from '@wordpress/a11y';
+import { useEffect, useMemo } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+export const useProgressBarLogic = (onGeneratingChange) => {
+ const { sortedViolations, altTextData, setAltTextData, isManage } =
+ useScannerWizardContext();
+ const {
+ isGenerating,
+ progress,
+ startBulkGeneration,
+ stopBulkGeneration,
+ resetProgress,
+ } = useBulkGeneration();
+
+ const altTextViolations = sortedViolations.altText;
+ const type = isManage ? 'manage' : 'main';
+ const totalImages = altTextViolations.length;
+
+ const handleStopGenerating = () => {
+ mixpanelService.sendEvent(mixpanelEvents.stopButtonClicked);
+ stopBulkGeneration();
+ speak(__('Generation stopped', 'pojo-accessibility'), 'assertive');
+ };
+
+ useEffect(() => {
+ if (onGeneratingChange) {
+ onGeneratingChange(isGenerating, handleStopGenerating);
+ }
+ }, [isGenerating, onGeneratingChange]);
+
+ useEffect(() => {
+ return () => {
+ resetProgress();
+ };
+ }, [resetProgress]);
+
+ const {
+ manuallySelectedCount,
+ completedSelectedCount,
+ areAllMarkedAsDecorative,
+ } = useMemo(() => {
+ let manuallySelected = 0;
+ let completedSelected = 0;
+ let allDecorative = true;
+
+ altTextViolations.forEach((item, index) => {
+ const itemData = altTextData?.[type]?.[index];
+ const isSelected = itemData?.selected === true;
+ const isDecorative = itemData?.makeDecorative === true;
+ const hasValidAlt = itemData?.hasValidAltText === true;
+
+ if (isSelected && !hasValidAlt) {
+ manuallySelected++;
+ }
+
+ if (isSelected && hasValidAlt) {
+ completedSelected++;
+ }
+
+ if (!isDecorative) {
+ allDecorative = false;
+ }
+ });
+
+ return {
+ manuallySelectedCount: manuallySelected,
+ completedSelectedCount: completedSelected,
+ areAllMarkedAsDecorative: allDecorative,
+ };
+ }, [altTextViolations, altTextData, type]);
+
+ const showManualSelectionMode = manuallySelectedCount > 0;
+
+ const handleClearSelection = () => {
+ const updatedData = [...(altTextData?.[type] || [])];
+
+ altTextViolations.forEach((item, index) => {
+ if (
+ altTextData?.[type]?.[index]?.selected &&
+ !altTextData?.[type]?.[index]?.hasValidAltText
+ ) {
+ updatedData[index] = {
+ ...(updatedData[index] || {}),
+ selected: false,
+ };
+ }
+ });
+
+ setAltTextData({
+ ...altTextData,
+ [type]: updatedData,
+ });
+ speak(__('Selection cleared', 'pojo-accessibility'), 'polite');
+ };
+
+ const handleMarkSelectedAsDecorative = () => {
+ const updatedData = [...(altTextData?.[type] || [])];
+ let count = 0;
+
+ altTextViolations.forEach((item, index) => {
+ if (
+ altTextData?.[type]?.[index]?.selected &&
+ !altTextData?.[type]?.[index]?.hasValidAltText
+ ) {
+ updatedData[index] = {
+ ...(updatedData[index] || {}),
+ makeDecorative: true,
+ hasValidAltText: true,
+ isDraft: false,
+ altText: '',
+ apiId: null,
+ resolved: false,
+ };
+ count++;
+ }
+ });
+
+ setAltTextData({
+ ...altTextData,
+ [type]: updatedData,
+ });
+
+ const message = sprintf(
+ // Translators: %d number of images marked as decorative
+ __('%d images marked as decorative', 'pojo-accessibility'),
+ count,
+ );
+ speak(message, 'polite');
+ };
+
+ const handleToggleAllDecorative = () => {
+ const updatedData = [...(altTextData?.[type] || [])];
+ const isMarking = !areAllMarkedAsDecorative;
+
+ altTextViolations.forEach((item, index) => {
+ if (isMarking) {
+ updatedData[index] = {
+ ...(updatedData[index] || {}),
+ makeDecorative: true,
+ selected: true,
+ hasValidAltText: true,
+ apiId: null,
+ resolved: false,
+ };
+ } else {
+ const currentAltText = updatedData[index]?.altText?.trim();
+ const hasAltText = !!currentAltText;
+
+ updatedData[index] = {
+ ...(updatedData[index] || {}),
+ makeDecorative: false,
+ selected: hasAltText,
+ hasValidAltText: hasAltText,
+ apiId: null,
+ resolved: false,
+ };
+ }
+ });
+
+ setAltTextData({
+ ...altTextData,
+ [type]: updatedData,
+ });
+
+ const message = isMarking
+ ? sprintf(
+ // Translators: %d number of images
+ __('All %d images marked as decorative', 'pojo-accessibility'),
+ totalImages,
+ )
+ : __('All images unmarked as decorative', 'pojo-accessibility');
+ speak(message, 'polite');
+ };
+
+ const handleGenerateAll = () => {
+ const hasManuallySelectedItems = altTextViolations.some(
+ (item, index) =>
+ altTextData?.[type]?.[index]?.selected === true &&
+ !altTextData?.[type]?.[index]?.hasValidAltText,
+ );
+
+ const cardIndicesToProcess = [];
+ altTextViolations.forEach((item, index) => {
+ const itemData = altTextData?.[type]?.[index];
+ const isMarkedDecorative = itemData?.makeDecorative;
+ const hasValidAlt = itemData?.hasValidAltText;
+
+ if (isMarkedDecorative || hasValidAlt) {
+ return;
+ }
+
+ const shouldInclude = hasManuallySelectedItems
+ ? itemData?.selected === true
+ : true;
+ if (shouldInclude) {
+ cardIndicesToProcess.push(index);
+ }
+ });
+
+ const message = sprintf(
+ // Translators: %d number of images to generate
+ __('Generating alt text for %d images', 'pojo-accessibility'),
+ cardIndicesToProcess.length,
+ );
+ speak(message, 'polite');
+ startBulkGeneration(cardIndicesToProcess);
+ };
+
+ const getGenerateButtonText = () => {
+ if (isGenerating) {
+ return __('Generating…', 'pojo-accessibility');
+ }
+ if (manuallySelectedCount > 0) {
+ return sprintf(
+ // Translators: %d number of images to generate
+ __('Generate (%d)', 'pojo-accessibility'),
+ manuallySelectedCount,
+ );
+ }
+ return __('Generate all', 'pojo-accessibility');
+ };
+
+ return {
+ // State
+ isGenerating,
+ progress,
+ showManualSelectionMode,
+ manuallySelectedCount,
+ completedSelectedCount,
+ totalImages,
+ areAllMarkedAsDecorative,
+
+ // Handlers
+ handleStopGenerating,
+ handleClearSelection,
+ handleMarkSelectedAsDecorative,
+ handleToggleAllDecorative,
+ handleGenerateAll,
+
+ // Computed values
+ generateButtonText: getGenerateButtonText(),
+ };
+};
diff --git a/modules/scanner/assets/js/icons/pencil-tick-icon.js b/modules/scanner/assets/js/icons/pencil-tick-icon.js
new file mode 100644
index 00000000..bd5520b1
--- /dev/null
+++ b/modules/scanner/assets/js/icons/pencil-tick-icon.js
@@ -0,0 +1,21 @@
+import SvgIcon from '@elementor/ui/SvgIcon';
+
+const PencilTickIcon = (props, { size = 'tiny' }) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PencilTickIcon;
diff --git a/modules/scanner/assets/js/icons/pencil-undo-icon.js b/modules/scanner/assets/js/icons/pencil-undo-icon.js
new file mode 100644
index 00000000..b2b93440
--- /dev/null
+++ b/modules/scanner/assets/js/icons/pencil-undo-icon.js
@@ -0,0 +1,21 @@
+import SvgIcon from '@elementor/ui/SvgIcon';
+
+const PencilUndoIcon = (props, { size = 'tiny' }) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PencilUndoIcon;
diff --git a/modules/scanner/assets/js/icons/player-stop-icon.js b/modules/scanner/assets/js/icons/player-stop-icon.js
new file mode 100644
index 00000000..4be2ee84
--- /dev/null
+++ b/modules/scanner/assets/js/icons/player-stop-icon.js
@@ -0,0 +1,14 @@
+import SvgIcon from '@elementor/ui/SvgIcon';
+
+const PlayerStopIcon = (props, { size = 'tiny' }) => {
+ return (
+
+
+
+ );
+};
+
+export default PlayerStopIcon;
diff --git a/modules/scanner/assets/js/icons/wand-icon.js b/modules/scanner/assets/js/icons/wand-icon.js
new file mode 100644
index 00000000..1434ca71
--- /dev/null
+++ b/modules/scanner/assets/js/icons/wand-icon.js
@@ -0,0 +1,28 @@
+import SvgIcon from '@elementor/ui/SvgIcon';
+
+const WandIcon = (props, { size = 'tiny' }) => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default WandIcon;
diff --git a/modules/scanner/assets/js/images/bulk-1.png b/modules/scanner/assets/js/images/bulk-1.png
new file mode 100644
index 00000000..04d4ab36
Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-1.png differ
diff --git a/modules/scanner/assets/js/images/bulk-2.png b/modules/scanner/assets/js/images/bulk-2.png
new file mode 100644
index 00000000..fce3ebd5
Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-2.png differ
diff --git a/modules/scanner/assets/js/images/bulk-3.png b/modules/scanner/assets/js/images/bulk-3.png
new file mode 100644
index 00000000..15d7eaa5
Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-3.png differ
diff --git a/modules/scanner/assets/js/images/bulk-4.png b/modules/scanner/assets/js/images/bulk-4.png
new file mode 100644
index 00000000..d30d3121
Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-4.png differ
diff --git a/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js b/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js
index 06b0e55c..dd99fb2f 100644
--- a/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js
+++ b/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js
@@ -41,7 +41,7 @@ export const AltTextLayout = () => {
};
return (
-
+
{
+ const makeAttributeData = () => {
+ if (makeDecorative) {
+ return {
+ attribute_name: 'role',
+ attribute_value: 'presentation',
+ };
+ }
+
+ if (item.node.tagName === 'svg') {
+ return {
+ attribute_name: 'aria-label',
+ attribute_value: altText,
+ };
+ }
+
+ return {
+ attribute_name: 'alt',
+ attribute_value: altText,
+ };
+ };
+
+ const match = item.node.className.toString().match(/wp-image-(\d+)/);
+ const finalAltText = !makeDecorative ? altText : '';
+ const find = item.snippet;
+
+ try {
+ if (match && item.node.tagName !== 'svg') {
+ void APIScanner.submitAltText(item.node.src, finalAltText);
+ }
+ const response = await APIScanner.submitRemediation({
+ url: window?.ea11yScannerData?.pageData.url,
+ remediation: {
+ ...makeAttributeData(),
+ action: 'add',
+ xpath: item.path.dom,
+ find,
+ category: item.reasonCategory.match(/\((AAA?|AA?|A)\)/)?.[1] || '',
+ type: 'ATTRIBUTE',
+ },
+ global: isGlobal,
+ rule: item.ruleId,
+ group: BLOCKS.altText,
+ apiId,
+ });
+
+ await APIScanner.resolveIssue(currentScanId);
+ void updateRemediationList();
+ return response.remediation;
+ } catch (e) {
+ console.warn(e);
+ throw e;
+ }
+};
diff --git a/modules/scanner/rest/generate-alt-text.php b/modules/scanner/rest/generate-alt-text.php
index 1fcce3b9..07162102 100644
--- a/modules/scanner/rest/generate-alt-text.php
+++ b/modules/scanner/rest/generate-alt-text.php
@@ -5,6 +5,9 @@
use EA11y\Modules\Scanner\Classes\Route_Base;
use EA11y\Modules\Scanner\Classes\Utils;
use EA11y\Classes\Utils as Global_Utils;
+use EA11y\Classes\Client\Client_Response;
+use EA11y\Classes\Exceptions\Quota_Exceeded_Error;
+use EA11y\Classes\Exceptions\Quota_API_Error;
use Throwable;
use WP_Error;
use WP_REST_Response;
@@ -49,21 +52,16 @@ public function POST( $request ) {
$src = $image ?? Utils::create_tmp_file_from_png_base64( $svg );
- $result = Global_Utils::get_api_client()->make_request(
- 'POST',
- 'ai/image-alt',
- [],
- [],
- false,
- $src
- );
-
- if ( is_wp_error( $result ) ) {
- return $this->respond_error_json( [
- 'message' => 'Failed to generate Alt Text',
- 'code' => 'internal_server_error',
- ] );
- }
+ $result = ( new Client_Response(
+ Global_Utils::get_api_client()->make_request(
+ 'POST',
+ 'ai/image-alt',
+ [],
+ [],
+ false,
+ $src
+ )
+ ) )->handle();
return $this->respond_success_json( [
'message' => 'Alt text generated',
@@ -73,6 +71,16 @@ public function POST( $request ) {
],
] );
+ } catch ( Quota_Exceeded_Error $e ) {
+ return $this->respond_error_json( [
+ 'message' => 'AI credits quota has been exceeded.',
+ 'code' => 'quota_exceeded',
+ ] );
+ } catch ( Quota_API_Error $e ) {
+ return $this->respond_error_json( [
+ 'message' => 'Quota API error. Try again after sometime.',
+ 'code' => 'quota_api_error',
+ ] );
} catch ( Throwable $t ) {
return $this->respond_error_json( [
'message' => $t->getMessage(),
diff --git a/modules/settings/assets/css/style.css b/modules/settings/assets/css/style.css
index 113a5611..f156afb6 100644
--- a/modules/settings/assets/css/style.css
+++ b/modules/settings/assets/css/style.css
@@ -17,17 +17,28 @@ body {
}
#ea11y-app {
- position: relative;
+ position: fixed;
z-index: 1;
+ height: calc(100vh - 32px);
+ inset-block: 32px 0; /* 32px is for WP admin bar. */
+ inset-inline: 180px 0;
margin-inline-start: -20px;
background: #fff;
- height: calc(100vh - 32px);
+}
+
+body.folded #ea11y-app {
+ inset-inline-start: 36px;
+ margin-inline-start: 0;
}
#ea11y-app * {
box-sizing: border-box;
}
+#ea11y-app .wrap {
+ padding: 20px 40px 0;
+}
+
.ea11y-textfield .MuiInputBase-input {
height: 40px;
padding: 8px 12px;
diff --git a/modules/settings/assets/js/admin.js b/modules/settings/assets/js/admin.js
index ca065f1e..014681dd 100644
--- a/modules/settings/assets/js/admin.js
+++ b/modules/settings/assets/js/admin.js
@@ -3,6 +3,7 @@ import { StrictMode, Fragment, createRoot } from '@wordpress/element';
import App from './app';
import { AnalyticsContextProvider } from './contexts/analytics-context';
import { PluginSettingsProvider } from './contexts/plugin-settings';
+import { initNoticeRelocation } from './utils/move-notices';
const rootNode = document.getElementById('ea11y-app');
@@ -10,18 +11,23 @@ const rootNode = document.getElementById('ea11y-app');
const isDevelopment = window?.ea11ySettingsData?.isDevelopment;
const AppWrapper = Boolean(isDevelopment) ? StrictMode : Fragment;
-const root = createRoot(rootNode);
+if (rootNode) {
+ const root = createRoot(rootNode);
-root.render(
-
-
-
-
-
-
-
-
-
-
- ,
-);
+ root.render(
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ // Move WordPress admin notices into the React app after rendering starts
+ initNoticeRelocation();
+}
diff --git a/modules/settings/assets/js/app.js b/modules/settings/assets/js/app.js
index cbc564ba..8b9d094d 100644
--- a/modules/settings/assets/js/app.js
+++ b/modules/settings/assets/js/app.js
@@ -7,14 +7,6 @@ import Box from '@elementor/ui/Box';
import DirectionProvider from '@elementor/ui/DirectionProvider';
import Grid from '@elementor/ui/Grid';
import { styled, ThemeProvider } from '@elementor/ui/styles';
-import {
- ConnectModal,
- GetStartedModal,
- MenuItems,
- OnboardingModal,
- PostConnectModal,
- UrlMismatchModal,
-} from '@ea11y/components';
import {
useNotificationSettings,
useSavedSettings,
@@ -23,11 +15,44 @@ import {
import { QuotaNotices, Sidebar } from '@ea11y/layouts';
import Notifications from '@ea11y-apps/global/components/notifications';
import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
-import { useEffect } from '@wordpress/element';
+import { lazy, Suspense, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
+import { MenuItems } from './components/sidebar-menu/menu';
import { usePluginSettingsContext } from './contexts/plugin-settings';
import PageContent from './page-content';
+// Lazy load modals - load them only when needed
+const ConnectModal = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-modal-connect" */ './components/connect-modal'
+ ),
+);
+const PostConnectModal = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-modal-post-connect" */ './components/post-connect-modal'
+ ),
+);
+const UrlMismatchModal = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-modal-url-mismatch" */ './components/url-mismatch-modal'
+ ),
+);
+const OnboardingModal = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-modal-onboarding" */ './components/onboarding-modal'
+ ),
+);
+const GetStartedModal = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-modal-get-started" */ './components/help-menu/get-started-modal'
+ ),
+);
+
const App = () => {
const { ea11ySettingsData } = window;
const { hasFinishedResolution, loading } = useSavedSettings();
@@ -74,13 +99,15 @@ const App = () => {
onDisconnect={refreshPluginSettings}
/>
- {isConnected !== undefined && !isUrlMismatch && !isConnected && (
-
- )}
- {isConnected && !closePostConnectModal && }
- {isUrlMismatch && }
-
-
+
+ {isConnected !== undefined && !isUrlMismatch && !isConnected && (
+
+ )}
+ {isConnected && !closePostConnectModal && }
+ {isUrlMismatch && }
+
+
+
diff --git a/modules/settings/assets/js/components/analytics/charts-list.js b/modules/settings/assets/js/components/analytics/charts-list.js
index 80b2df74..49562695 100644
--- a/modules/settings/assets/js/components/analytics/charts-list.js
+++ b/modules/settings/assets/js/components/analytics/charts-list.js
@@ -23,6 +23,7 @@ import {
} from '@ea11y/components/analytics/skeleton';
import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
import { dateI18n } from '@wordpress/date';
+import { Suspense } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { useAnalyticsContext } from '../../contexts/analytics-context';
@@ -152,10 +153,22 @@ export const ChartsList = () => {
- {isLoading ? : }
+ {isLoading ? (
+
+ ) : (
+ }>
+
+
+ )}
- {isLoading ? : }
+ {isLoading ? (
+
+ ) : (
+ }>
+
+
+ )}
{isLoading ? (
diff --git a/modules/settings/assets/js/components/analytics/charts/index.js b/modules/settings/assets/js/components/analytics/charts/index.js
index 28254e20..99a3c93a 100644
--- a/modules/settings/assets/js/components/analytics/charts/index.js
+++ b/modules/settings/assets/js/components/analytics/charts/index.js
@@ -1,3 +1,16 @@
-export { LineChart } from './line-chart';
-export { PieChart } from './pie-chart';
+import { lazy } from '@wordpress/element';
+
+// Lazy-load chart components to split @mui/x-charts into separate webpack chunks
+export const LineChart = lazy(() =>
+ import(/* webpackChunkName: "chunk-charts-analytics" */ './line-chart').then(
+ (module) => ({ default: module.LineChart }),
+ ),
+);
+
+export const PieChart = lazy(() =>
+ import(/* webpackChunkName: "chunk-charts-analytics" */ './pie-chart').then(
+ (module) => ({ default: module.PieChart }),
+ ),
+);
+
export { UsageTable } from './usage-table';
diff --git a/modules/settings/assets/js/components/bottom-bar/index.js b/modules/settings/assets/js/components/bottom-bar/index.js
index e39d8cf4..74bbd8bf 100644
--- a/modules/settings/assets/js/components/bottom-bar/index.js
+++ b/modules/settings/assets/js/components/bottom-bar/index.js
@@ -11,6 +11,7 @@ const BottomBar = () => {
selectedMenu,
widgetMenuSettings,
skipToContentSettings,
+ widgetActivationSettings,
iconDesign,
iconPosition,
hasChanges,
@@ -27,6 +28,7 @@ const BottomBar = () => {
savedData = {
ea11y_widget_menu_settings: widgetMenuSettings,
ea11y_skip_to_content_settings: skipToContentSettings,
+ ea11y_widget_activation_settings: widgetActivationSettings,
};
} else if (selectedMenu.child === 'design') {
savedData = {
diff --git a/modules/settings/assets/js/components/index.js b/modules/settings/assets/js/components/index.js
index 7ec90b9b..57546444 100644
--- a/modules/settings/assets/js/components/index.js
+++ b/modules/settings/assets/js/components/index.js
@@ -1,4 +1,5 @@
-export { default as ConnectModal } from './connect-modal';
+// ConnectModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling
+// export { default as ConnectModal } from './connect-modal';
export { default as MyAccountMenu } from './my-account-menu';
export { default as PopupMenu } from './my-account-menu/popup-menu';
export { default as HelpMenu } from './help-menu';
@@ -13,11 +14,13 @@ export { default as AlignmentMatrixControl } from './alignment-matrix-control';
export { default as PositionControl } from './position-control';
export { MenuItems } from '../components/sidebar-menu/menu';
export { default as BottomBar } from './bottom-bar';
-export { default as PostConnectModal } from './post-connect-modal';
+// PostConnectModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling
+// export { default as PostConnectModal } from './post-connect-modal';
export { default as StatementGenerator } from './statement-generator';
export { default as AlertError } from './error';
export { default as HtmlToTypography } from './html-to-typography';
export { default as WidgetLoader } from './widget-loader';
+export { default as WidgetActivationSettings } from './widget-activation-settings';
export { default as CopyLink } from './copy-link';
export { default as EditLink } from './edit-link';
export { default as GeneratedPageInfoTipCard } from './generated-page-infotip-card';
@@ -26,7 +29,8 @@ export { default as CapabilitiesItem } from './capabilities-item';
export { default as ProItemInfotip } from './capabilities-item/pro-item-infotip';
export { default as CustomSwitch } from './switch';
export { default as ConfirmDialog } from '@ea11y-apps/global/components/confirm-dialog';
-export { default as UrlMismatchModal } from './url-mismatch-modal';
+// UrlMismatchModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling
+// export { default as UrlMismatchModal } from './url-mismatch-modal';
export { default as WidgetIcon } from './widget-icon';
export { default as CustomIcon } from './custom-icon';
export { default as IconOptionWrapper } from './icon-option-wrapper';
@@ -35,5 +39,7 @@ export { default as QuotaBar } from './quota-bar';
export { default as QuotaIndicator } from './quota-bar/quota-indicator';
export { default as MenuItem } from './sidebar-menu/menu-item';
export { default as QuotaBarGroup } from './quota-bar/quota-bar-group';
-export { default as OnboardingModal } from './onboarding-modal';
-export { default as GetStartedModal } from './help-menu/get-started-modal';
+// OnboardingModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling
+// export { default as OnboardingModal } from './onboarding-modal';
+// GetStartedModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling
+// export { default as GetStartedModal } from './help-menu/get-started-modal';
diff --git a/modules/settings/assets/js/components/url-mismatch-modal/index.js b/modules/settings/assets/js/components/url-mismatch-modal/index.js
index b70cc76d..aaf06455 100644
--- a/modules/settings/assets/js/components/url-mismatch-modal/index.js
+++ b/modules/settings/assets/js/components/url-mismatch-modal/index.js
@@ -3,8 +3,8 @@ import Button from '@elementor/ui/Button';
import Grid from '@elementor/ui/Grid';
import Typography from '@elementor/ui/Typography';
import { styled } from '@elementor/ui/styles';
-import { ConfirmDialog } from '@ea11y/components';
import { useModal } from '@ea11y/hooks';
+import ConfirmDialog from '@ea11y-apps/global/components/confirm-dialog';
import { ONE_MISMATCH_URL } from '@ea11y-apps/global/constants';
import { useToastNotification } from '@ea11y-apps/global/hooks';
import { useState } from '@wordpress/element';
diff --git a/modules/settings/assets/js/components/widget-activation-settings/index.js b/modules/settings/assets/js/components/widget-activation-settings/index.js
new file mode 100644
index 00000000..408f277d
--- /dev/null
+++ b/modules/settings/assets/js/components/widget-activation-settings/index.js
@@ -0,0 +1,113 @@
+import InfoCircleIcon from '@elementor/icons/InfoCircleIcon';
+import Box from '@elementor/ui/Box';
+import Card from '@elementor/ui/Card';
+import Infotip from '@elementor/ui/Infotip';
+import Switch from '@elementor/ui/Switch';
+import Typography from '@elementor/ui/Typography';
+import { styled } from '@elementor/ui/styles';
+import { useSettings } from '@ea11y/hooks';
+import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services';
+import { __ } from '@wordpress/i18n';
+
+const WidgetActivationSettings = () => {
+ const {
+ widgetActivationSettings,
+ setWidgetActivationSettings,
+ setHasChanges,
+ } = useSettings();
+
+ const toggleSetting = () => {
+ const newValue = !widgetActivationSettings.enabled;
+
+ setWidgetActivationSettings({
+ ...widgetActivationSettings,
+ enabled: newValue,
+ });
+
+ setHasChanges(true);
+
+ mixpanelService.sendEvent(mixpanelEvents.toggleClicked, {
+ state: newValue ? 'on' : 'off',
+ type: 'Widget activation',
+ });
+ };
+
+ return (
+
+
+
+ {__('Widget Activation', 'pojo-accessibility')}
+
+
+
+ {__('Widget Activation', 'pojo-accessibility')}
+
+
+
+ {__(
+ 'Disabling it will prevent the widget from loading entirely.',
+ 'pojo-accessibility',
+ )}
+
+
+ }
+ placement="right"
+ arrow={true}
+ >
+
+
+
+
+
+
+
+
+ {__(
+ 'Enable or disable the accessibility widget on your website.',
+ 'pojo-accessibility',
+ )}
+
+
+ );
+};
+
+const StyledCard = styled(Card)`
+ padding: ${({ theme }) => theme.spacing(2)};
+ margin-block: ${({ theme }) => theme.spacing(4)};
+ margin-inline: auto;
+ max-width: 1200px;
+`;
+
+const StyledBox = styled(Box)`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+`;
+
+const StyledSwitch = styled(Switch)`
+ input {
+ height: 56px !important;
+ }
+`;
+
+const StyledTypography = styled(Typography)`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+export default WidgetActivationSettings;
diff --git a/modules/settings/assets/js/hooks/use-saved-settings.js b/modules/settings/assets/js/hooks/use-saved-settings.js
index f6301305..a7ed1d16 100644
--- a/modules/settings/assets/js/hooks/use-saved-settings.js
+++ b/modules/settings/assets/js/hooks/use-saved-settings.js
@@ -18,6 +18,7 @@ export const useSavedSettings = () => {
setAccessibilityStatementData,
setShowAccessibilityGeneratedInfotip,
setSkipToContentSettings,
+ setWidgetActivationSettings,
setDismissedQuotaNotices,
} = useSettings();
@@ -92,6 +93,7 @@ export const useSavedSettings = () => {
result.data.ea11y_accessibility_statement_data,
);
}
+
if (result?.data?.ea11y_show_accessibility_generated_page_infotip) {
setShowAccessibilityGeneratedInfotip(
result.data.ea11y_show_accessibility_generated_page_infotip,
@@ -102,6 +104,12 @@ export const useSavedSettings = () => {
setSkipToContentSettings(result?.data?.ea11y_skip_to_content_settings);
}
+ if (result?.data?.ea11y_widget_activation_settings) {
+ setWidgetActivationSettings(
+ result?.data?.ea11y_widget_activation_settings,
+ );
+ }
+
if (result?.data?.ea11y_analytics_enabled) {
setIsAnalyticsEnabled(result?.data?.ea11y_analytics_enabled);
}
diff --git a/modules/settings/assets/js/hooks/use-settings.js b/modules/settings/assets/js/hooks/use-settings.js
index c90b4294..8ea586d8 100644
--- a/modules/settings/assets/js/hooks/use-settings.js
+++ b/modules/settings/assets/js/hooks/use-settings.js
@@ -101,6 +101,10 @@ export const SettingsProvider = ({ children }) => {
enabled: true,
});
+ const [widgetActivationSettings, setWidgetActivationSettings] = useState({
+ enabled: true,
+ });
+
const [planData, setPlanData] = useState(null);
// Track settings changes to enable/disable Save Changes button
@@ -196,6 +200,8 @@ export const SettingsProvider = ({ children }) => {
setWidgetMenuSettings,
skipToContentSettings,
setSkipToContentSettings,
+ widgetActivationSettings,
+ setWidgetActivationSettings,
skipToContentHasChanges,
setSkipToContentHasChanges,
hideMinimumOptionAlert,
diff --git a/modules/settings/assets/js/page-content.js b/modules/settings/assets/js/page-content.js
index 12820ba6..63fb6f3b 100644
--- a/modules/settings/assets/js/page-content.js
+++ b/modules/settings/assets/js/page-content.js
@@ -1,3 +1,4 @@
+import { Suspense } from '@wordpress/element';
import SettingsLoader from './page-content-loader';
const PageContent = ({ isLoading, page }) => {
@@ -5,7 +6,7 @@ const PageContent = ({ isLoading, page }) => {
return ;
}
- return page;
+ return }>{page};
};
export default PageContent;
diff --git a/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js
index 9de11beb..29adf09f 100644
--- a/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js
+++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js
@@ -1,5 +1,6 @@
import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader';
import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip';
+import { lazy, Suspense } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
StyledStatsItem,
@@ -8,7 +9,12 @@ import {
StyledStatsItemTitle,
} from '../stats.styles';
import IssueList from './issue-list';
-import PieChart from './pie-chart';
+
+const PieChart = lazy(() =>
+ import(/* webpackChunkName: "chunk-charts-assistant" */ './pie-chart').then(
+ (module) => ({ default: module.default }),
+ ),
+);
const IssuesByCategory = ({ stats, loading, noResultsState }) => {
return (
@@ -33,11 +39,13 @@ const IssuesByCategory = ({ stats, loading, noResultsState }) => {
-
+ }>
+
+
);
diff --git a/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js
index 2e0a590c..e5080370 100644
--- a/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js
+++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js
@@ -1,5 +1,6 @@
import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader';
import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip';
+import { lazy, Suspense } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
StyledStatsItem,
@@ -8,7 +9,12 @@ import {
StyledStatsItemTitle,
} from '../stats.styles';
import IssueList from './issue-list';
-import PieChart from './pie-chart';
+
+const PieChart = lazy(() =>
+ import(/* webpackChunkName: "chunk-charts-assistant" */ './pie-chart').then(
+ (module) => ({ default: module.default }),
+ ),
+);
const IssuesByLevel = ({ stats, loading, noResultsState }) => {
const levelsTotal =
@@ -48,14 +54,16 @@ const IssuesByLevel = ({ stats, loading, noResultsState }) => {
-
+ }>
+
+
);
diff --git a/modules/settings/assets/js/pages/index.js b/modules/settings/assets/js/pages/index.js
index 8c051f68..c327e6bc 100644
--- a/modules/settings/assets/js/pages/index.js
+++ b/modules/settings/assets/js/pages/index.js
@@ -1,5 +1,23 @@
-export { default as AccessibilityStatement } from './accessibility-statement';
-export { default as Analytics } from './analytics';
-export { default as IconSettings } from './icon-settings';
-export { default as Menu } from './menu';
-export { default as AccessibilityAssistant } from './assistant';
+import { lazy } from '@wordpress/element';
+
+export const AccessibilityAssistant = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-page-accessibility-scans" */ './assistant'
+ ),
+);
+export const IconSettings = lazy(
+ () => import(/* webpackChunkName: "chunk-page-design" */ './icon-settings'),
+);
+export const Menu = lazy(
+ () => import(/* webpackChunkName: "chunk-page-capabilities" */ './menu'),
+);
+export const Analytics = lazy(
+ () => import(/* webpackChunkName: "chunk-page-analytics" */ './analytics'),
+);
+export const AccessibilityStatement = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "chunk-page-accessibility-statement" */ './accessibility-statement'
+ ),
+);
diff --git a/modules/settings/assets/js/pages/menu.js b/modules/settings/assets/js/pages/menu.js
index 1f546fe2..1d63a389 100644
--- a/modules/settings/assets/js/pages/menu.js
+++ b/modules/settings/assets/js/pages/menu.js
@@ -1,6 +1,6 @@
import Box from '@elementor/ui/Box';
import { styled } from '@elementor/ui/styles';
-import { BottomBar } from '@ea11y/components';
+import { BottomBar, WidgetActivationSettings } from '@ea11y/components';
import SkipToContentSettings from '@ea11y/components/skip-to-content-settings';
import { MenuSettings, WidgetPreview } from '@ea11y/layouts';
import {
@@ -32,6 +32,8 @@ const Menu = () => {
+
+
diff --git a/modules/settings/assets/js/utils/move-notices.js b/modules/settings/assets/js/utils/move-notices.js
new file mode 100644
index 00000000..dd92157b
--- /dev/null
+++ b/modules/settings/assets/js/utils/move-notices.js
@@ -0,0 +1,120 @@
+const NOTICE_SELECTORS = '.wrap .notice, .wrap .updated, .wrap .error';
+const TARGET_SELECTOR = '#ea11y-app > div > div + div';
+const PLUGIN_SELECTOR = '#ea11y-app';
+const MAX_WAIT_ATTEMPTS = 10;
+const RETRY_INTERVAL = 100; // ms
+
+/**
+ * Move notices from WordPress admin to the React app container.
+ *
+ * @return {boolean} True if target exists and notices were processed, false otherwise.
+ */
+function moveNotices() {
+ const targetContainer = document.querySelector(TARGET_SELECTOR);
+
+ if (!targetContainer) {
+ return false;
+ }
+
+ // Find or create a .wrap element inside the target container
+ let wrapElement = targetContainer.querySelector('.wrap');
+ if (!wrapElement) {
+ wrapElement = document.createElement('div');
+ wrapElement.className = 'wrap';
+ targetContainer.prepend(wrapElement);
+ }
+
+ // Find all notices in .wrap containers
+ const notices = document.querySelectorAll(NOTICE_SELECTORS);
+
+ notices.forEach((notice) => {
+ // Only move notices that are NOT already inside the app
+ if (!notice.closest(PLUGIN_SELECTOR)) {
+ wrapElement.prepend(notice);
+ }
+ });
+
+ return true;
+}
+
+/**
+ * Set up a MutationObserver to watch for dynamically-added notices.
+ */
+function setupNoticeObserver() {
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ // Check if the added node is a notice/error/updated element
+ if (
+ node.nodeType === Node.ELEMENT_NODE &&
+ (node.classList.contains('notice') ||
+ node.classList.contains('error') ||
+ node.classList.contains('updated'))
+ ) {
+ // Check if it's in a .wrap but not in the app
+ const isInWrap = node.closest('.wrap');
+ const isInApp = node.closest(PLUGIN_SELECTOR);
+
+ if (isInWrap && !isInApp) {
+ const targetContainer = document.querySelector(TARGET_SELECTOR);
+ if (targetContainer) {
+ // Find or create a .wrap element inside the target container
+ let wrapElement = targetContainer.querySelector('.wrap');
+ if (!wrapElement) {
+ wrapElement = document.createElement('div');
+ wrapElement.className = 'wrap';
+ targetContainer.prepend(wrapElement);
+ }
+ wrapElement.prepend(node);
+ }
+ }
+ }
+ });
+ });
+ });
+
+ // Observe the entire document body for added notices
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+
+ return observer;
+}
+
+/**
+ * Wait for the React target element to exist, then move notices.
+ *
+ * This function polls for the target element with a retry mechanism,
+ * since React needs time to render the DOM structure.
+ */
+function waitAndMoveNotices() {
+ // Try moving immediately in case React has already rendered
+ if (moveNotices()) {
+ setupNoticeObserver();
+ return;
+ }
+
+ // Set up polling to wait for React to render
+ let attempts = 0;
+ const interval = setInterval(() => {
+ if (moveNotices() || attempts++ >= MAX_WAIT_ATTEMPTS) {
+ clearInterval(interval);
+
+ // Only set up observer if we successfully found the target
+ if (attempts < MAX_WAIT_ATTEMPTS) {
+ setupNoticeObserver();
+ }
+ }
+ }, RETRY_INTERVAL);
+}
+
+/**
+ * Initialize the notice relocation system.
+ *
+ * Call this function after React has started rendering to begin
+ * moving WordPress admin notices into the React app container.
+ */
+export function initNoticeRelocation() {
+ waitAndMoveNotices();
+}
diff --git a/modules/settings/banners/onboarding-banner.php b/modules/settings/banners/onboarding-banner.php
index 2770d64f..f98a7503 100644
--- a/modules/settings/banners/onboarding-banner.php
+++ b/modules/settings/banners/onboarding-banner.php
@@ -91,11 +91,6 @@ public static function get_banner() {