diff --git a/.wporg-assets/screenshot-1.png b/.wporg-assets/screenshot-1.png index 59294901..afdbfd46 100644 Binary files a/.wporg-assets/screenshot-1.png and b/.wporg-assets/screenshot-1.png differ diff --git a/.wporg-assets/screenshot-2.png b/.wporg-assets/screenshot-2.png index fba4cb90..a6c74ff5 100644 Binary files a/.wporg-assets/screenshot-2.png and b/.wporg-assets/screenshot-2.png differ diff --git a/.wporg-assets/screenshot-3.png b/.wporg-assets/screenshot-3.png index f020fc58..f6b2eca2 100644 Binary files a/.wporg-assets/screenshot-3.png and b/.wporg-assets/screenshot-3.png differ diff --git a/.wporg-assets/screenshot-4.png b/.wporg-assets/screenshot-4.png index 088d3354..4d9180c9 100644 Binary files a/.wporg-assets/screenshot-4.png and b/.wporg-assets/screenshot-4.png differ diff --git a/.wporg-assets/screenshot-5.png b/.wporg-assets/screenshot-5.png index bc44bf9f..429c2782 100644 Binary files a/.wporg-assets/screenshot-5.png and b/.wporg-assets/screenshot-5.png differ diff --git a/.wporg-assets/screenshot-6.png b/.wporg-assets/screenshot-6.png new file mode 100644 index 00000000..51749ad1 Binary files /dev/null and b/.wporg-assets/screenshot-6.png differ diff --git a/.wporg-assets/screenshot-7.png b/.wporg-assets/screenshot-7.png new file mode 100644 index 00000000..1bfd5b18 Binary files /dev/null and b/.wporg-assets/screenshot-7.png differ diff --git a/README.md b/README.md index dc82ad68..b6e75ea0 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ **Requires at least:** 6.6 \ **Tested up to:** 6.8 \ **Requires PHP:** 7.4 \ -**Stable tag:** 3.6.0 \ +**Stable tag:** 3.8.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. ## Description +https://www.youtube.com/watch?v=-2ig5D348vo + Ally (formerly One Click Accessibility) is a free, powerful, and user-friendly plugin that helps WordPress creators build more accessible websites with ease. It simplifies accessibility with three essential tools: @@ -194,29 +196,53 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro ## Screenshots -### 1. Design: Customize the button's icon, size, and color and widget branding. +### 1. Usability widget: Large, well organized controls let visitors adjust text, contrast, animations, and more for a more comfortable browsing experience. + +![Usability widget: Large, well organized controls let visitors adjust text, contrast, animations, and more for a more comfortable browsing experience.](https://ps.w.org/pojo-accessibility/assets/screenshot-1.png) + +### 2. Accessibility button: Add a button that opens the accessibility menu in one click, and tailor its icon, color, label, and placement to match your brand. + +![Accessibility button: Add a button that opens the accessibility menu in one click, and tailor its icon, color, label, and placement to match your brand.](https://ps.w.org/pojo-accessibility/assets/screenshot-2.png) + +### 3. Accessibility statement: Quickly generate and publish a custom statement that signals your commitment, improves transparency, and offers a clear way to report issues. -![Design: Customize the button's icon, size, and color and widget branding.](https://ps.w.org/pojo-accessibility/assets/screenshot-1.png) +![Accessibility statement: Quickly generate and publish a custom statement that signals your commitment, improves transparency, and offers a clear way to report issues.](https://ps.w.org/pojo-accessibility/assets/screenshot-3.png) -### 2. Position Settings: Set widget placement or hide it on desktop and mobile. +### 4. Accessibility Assistant: Scan any page to instantly detect over 180 common accessibility issues and get clear, guided steps for remediation inside your site editor. -![Position Settings: Set widget placement or hide it on desktop and mobile.](https://ps.w.org/pojo-accessibility/assets/screenshot-2.png) +![Accessibility Assistant: Scan any page to instantly detect over 180 common accessibility issues and get clear, guided steps for remediation inside your site editor.](https://ps.w.org/pojo-accessibility/assets/screenshot-4.png) -### 3. Capabilities: Toggle accessibility features on or off as needed. +### 5. Scan results: View issues highlighted in context and grouped by type such as alt text, ARIA, page structure, and more. Expand any item for step-by-step guidance and optional AI-powered suggestions. -![Capabilities: Toggle accessibility features on or off as needed.](https://ps.w.org/pojo-accessibility/assets/screenshot-3.png) +![Scan results: View issues highlighted in context and grouped by type such as alt text, ARIA, page structure, and more. Expand any item for step-by-step guidance and optional AI-powered suggestions.](https://ps.w.org/pojo-accessibility/assets/screenshot-5.png) -### 4. Accessibility Statement: Add or create a custom accessibility statement. +### 6. Color contrast: Fine tune text and background colors with live checks that validate contrast ratios. -![Accessibility Statement: Add or create a custom accessibility statement.](https://ps.w.org/pojo-accessibility/assets/screenshot-4.png) +![Color contrast: Fine tune text and background colors with live checks that validate contrast ratios.](https://ps.w.org/pojo-accessibility/assets/screenshot-6.png) -### 5. Widget on Site: This is how the accessibility widget appears on a live website. +### 7. Scanner dashboard: Track your site’s accessibility scans, monitor open issues, and follow progress over time. -![Widget on Site: This is how the accessibility widget appears on a live website.](https://ps.w.org/pojo-accessibility/assets/screenshot-5.png) +![Scanner dashboard: Track your site’s accessibility scans, monitor open issues, and follow progress over time.](https://ps.w.org/pojo-accessibility/assets/screenshot-7.png) ## Changelog +### 3.8.0 – 2025-09-29 + +* New: Headings Remediation Management - Review and correct heading hierarchy with a guided flow +* Tweak: Improved AI remediation results with added context for clearer responses +* Fix: Disabled remediation action not working +* Fix: Quota number displayed incorrectly for Standard plan + +### 3.7.0 - 2025-09-02 + +* New: Redesigned Accessibility Widget – Clearer structure, Wider accessible buttons, and Improved mobile view +* New: Intro banner for users who connected Ally +* New: Reviews & CSAT flow to gather user feedback +* Tweak: Added cache clearing option in Assistant panel and WordPress Admin Bar +* Tweak: Enhanced color contrast evaluation to fix issues with gradient/video backgrounds +* Fix: found issue count wrong in edge cases + ### 3.6.0 - 2025-08-02 * New: Smart color contrast remediation flow in the accessibility assistant @@ -226,7 +252,6 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro * Fix: Added WPML compatibility * Fix: WooCommerce AJAX conflict - ### 3.5.2 - 2025-07-28 * Tweak: Improved performance by enqueuing Assistant only when logged in diff --git a/assets/dev/js/services/mixpanel/mixpanel-events.js b/assets/dev/js/services/mixpanel/mixpanel-events.js index 689ee84b..de113e44 100644 --- a/assets/dev/js/services/mixpanel/mixpanel-events.js +++ b/assets/dev/js/services/mixpanel/mixpanel-events.js @@ -69,4 +69,8 @@ export const mixpanelEvents = { feedbackSubmitted: 'review_feedback_submitted', publicRedirectClicked: 'review_public_redirect_clicked', }, + + // Heading Structure + headingClicked: 'heading_clicked', + headingSelected: 'heading_selected', }; diff --git a/assets/dev/js/utils/inject-template-vars.js b/assets/dev/js/utils/inject-template-vars.js new file mode 100644 index 00000000..3a67ddcc --- /dev/null +++ b/assets/dev/js/utils/inject-template-vars.js @@ -0,0 +1,21 @@ +import { createElement } from '@wordpress/element'; + +export const injectTemplateVars = (message, components) => { + const regex = /\{\{(\w+)\}\}([^]*?)\{\{\/\1\}\}/g; + const splitMessage = message.split(regex); + + // eslint-disable-next-line array-callback-return + return splitMessage.map((part, index) => { + if (index % 3 === 0) { + return part; + } + + if (index % 3 === 1) { + return createElement( + components[part], + { key: index }, + splitMessage[index + 1], + ); + } + }); +}; diff --git a/modules/connect/classes/data.php b/modules/connect/classes/data.php index ad1b758f..02cb08e9 100644 --- a/modules/connect/classes/data.php +++ b/modules/connect/classes/data.php @@ -191,7 +191,8 @@ public static function get_refresh_token() { public static function get_home_url() { $raw = self::get_connect_mode_data( self::HOME_URL, false ); $is_base64 = base64_encode( base64_decode( $raw, true ) ) === $raw; - return $is_base64 ? base64_decode( $raw ) : $raw; + $url = $is_base64 ? base64_decode( $raw ) : $raw; + return apply_filters( 'ally_connect_home_url', $url ); } /** diff --git a/modules/core/components/skip-link.php b/modules/core/components/skip-link.php index 783c755a..2babb0f3 100644 --- a/modules/core/components/skip-link.php +++ b/modules/core/components/skip-link.php @@ -71,6 +71,7 @@ public function __construct() { if ( $this->settings && ! empty( $this->settings['enabled'] ) ) { remove_action( 'wp_footer', 'the_block_template_skip_link' ); + add_filter( 'hello_elementor_enable_skip_link', '__return_false' ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_skip_link_styles' ] ); add_action( 'wp_body_open', [ $this, 'render_skip_link' ] ); diff --git a/modules/remediation/classes/utils.php b/modules/remediation/classes/utils.php index ecf5053e..bcd27edb 100644 --- a/modules/remediation/classes/utils.php +++ b/modules/remediation/classes/utils.php @@ -122,7 +122,11 @@ public static function get_current_object_type_name() : string { } if ( $wp_query->is_post_type_archive() ) { - return $wp_query->query_vars['post_type'] ?? 'unknown'; + $post_type = $wp_query->query_vars['post_type']; + if ( is_array( $post_type ) ) { + $post_type = implode( '_', $post_type ); + } + return $post_type ?? 'unknown'; } if ( $wp_query->is_singular() ) { diff --git a/modules/remediation/components/remediation-runner.php b/modules/remediation/components/remediation-runner.php index 9fa99f42..c858f656 100644 --- a/modules/remediation/components/remediation-runner.php +++ b/modules/remediation/components/remediation-runner.php @@ -148,7 +148,7 @@ private function should_run_remediation(): bool { ]); $this->page_html = $this->page->get_page_html(); - $this->page_remediations = Remediation_Entry::get_page_remediations( $current_url ); + $this->page_remediations = Remediation_Entry::get_page_active_remediations( $current_url ); $status = $this->page->__get( Page_Table::STATUS ); if ( empty( $this->page_remediations ) || Page_Table::STATUSES['ACTIVE'] !== $status ) { diff --git a/modules/remediation/database/remediation-entry.php b/modules/remediation/database/remediation-entry.php index 37c927fc..7102c998 100644 --- a/modules/remediation/database/remediation-entry.php +++ b/modules/remediation/database/remediation-entry.php @@ -79,6 +79,30 @@ public static function get_page_remediations( string $url, bool $total = false ) return Remediation_Table::select( $select, $where ); } + /** + * get_page_active_remediations + * + * @param string $url + * @return array + */ + public static function get_page_active_remediations( string $url ) : array { + $where = [ + [ + 'column' => Remediation_Table::URL, + 'value' => $url, + 'operator' => '=', + 'relation_after' => 'AND', + ], + [ + 'column' => Remediation_Table::ACTIVE, + 'value' => 1, + 'operator' => '=', + ], + ]; + + return Remediation_Table::select( '*', $where ); + } + public static function get_all_remediations( int $period ) : array { $date_threshold = gmdate( 'Y-m-d', strtotime( "-{$period} days" ) ) . ' 00:00:00'; diff --git a/modules/remediation/module.php b/modules/remediation/module.php index 4de0999a..1c03c97a 100644 --- a/modules/remediation/module.php +++ b/modules/remediation/module.php @@ -28,6 +28,8 @@ public static function routes_list(): array { 'Items', 'Item', 'Trigger_Save', + 'Heading_Level', + 'Dismiss_Heading_Issue', 'Clear_Cache', ]; } diff --git a/modules/remediation/rest/dismiss-heading-issue.php b/modules/remediation/rest/dismiss-heading-issue.php new file mode 100644 index 00000000..d161a210 --- /dev/null +++ b/modules/remediation/rest/dismiss-heading-issue.php @@ -0,0 +1,69 @@ +verify_capability(); + + if ( $error ) { + return $error; + } + + $post_id = (int) sanitize_text_field( $request->get_param( 'pageId' ) ); + $xpath = sanitize_text_field( $request->get_param( 'xpath' ) ); + + if ( ! $post_id || ! $xpath ) { + return $this->respond_error_json( [ + 'message' => 'Missing required parameters', + 'code' => 'missing_parameters', + ] ); + } + + $data = get_post_meta( $post_id, self::META_KEY, true ) ?? []; + + if ( ! $data ) { + $data = []; + } + + $data[] = $xpath; + + update_post_meta( $post_id, self::META_KEY, array_unique( $data ) ); + + return $this->respond_success_json( [ + 'message' => 'Heading issue dismissed', + ] ); + } catch ( Throwable $t ) { + return $this->respond_error_json( [ + 'message' => $t->getMessage(), + 'code' => 'internal_server_error', + ] ); + } + } +} diff --git a/modules/remediation/rest/heading-level.php b/modules/remediation/rest/heading-level.php new file mode 100644 index 00000000..a1d0e13e --- /dev/null +++ b/modules/remediation/rest/heading-level.php @@ -0,0 +1,123 @@ +verify_capability(); + + if ( $error ) { + return $error; + } + + $url = esc_url( $request->get_param( 'url' ) ); + $level = sanitize_text_field( $request->get_param( 'level' ) ); + $xpath = sanitize_text_field( $request->get_param( 'xpath' ) ); + $rule = sanitize_text_field( $request->get_param( 'rule' ) ) ?? ''; + + // Remove existing remediations for this xpath before creating new ones + $this->remove_existing_remediations( $url, $xpath ); + + if ( 'p' === $level ) { + $this + ->create_remediation_entry( $url, $rule, [ + 'xpath' => $xpath, + 'attribute_name' => 'role', + 'attribute_value' => 'presentation', + ] ) + ->save(); + } else { + $this + ->create_remediation_entry( $url, $rule, [ + 'xpath' => $xpath, + 'attribute_name' => 'role', + 'attribute_value' => 'heading', + ] ) + ->save(); + + $this + ->create_remediation_entry( $url, $rule, [ + 'xpath' => $xpath, + 'attribute_name' => 'aria-level', + 'attribute_value' => trim( $level, 'h' ), + ] ) + ->save(); + } + + Page_Entry::clear_cache( $url ); + + return $this->respond_success_json( [ + 'message' => 'Remediation added', + ] ); + } catch ( Throwable $t ) { + return $this->respond_error_json( [ + 'message' => $t->getMessage(), + 'code' => 'internal_server_error', + ] ); + } + } + + private function remove_existing_remediations( string $url, string $xpath ): void { + $existing_entries = Remediation_Entry::get_page_remediations( $url ); + + foreach ( $existing_entries as $entry ) { + if ( isset( $entry->group ) && 'headingStructure' === $entry->group ) { + $content = json_decode( $entry->content, true ); + + if ( isset( $content['xpath'] ) && + $content['xpath'] === $xpath && + isset( $content['type'] ) && + 'ATTRIBUTE' === $content['type'] && + isset( $content['attribute_name'] ) && + in_array( $content['attribute_name'], [ 'role', 'aria-level' ], true ) + ) { + Remediation_Table::delete( [ 'id' => $entry->id ] ); + } + } + } + } + + private function create_remediation_entry( string $url, string $rule, array $data ): Remediation_Entry { + return new Remediation_Entry( [ + 'data' => [ + Remediation_Table::URL => $url, + Remediation_Table::CATEGORY => 'A', + Remediation_Table::RULE => $rule, + Remediation_Table::GROUP => 'headingStructure', + Remediation_Table::CONTENT => wp_json_encode( array_merge( [ + 'category' => 'A', + 'type' => 'ATTRIBUTE', + 'action' => 'update', + ], $data ) ), + Remediation_Table::ACTIVE => true, + ], + ] ); + } +} diff --git a/modules/scanner/assets/js/api/APIScanner.js b/modules/scanner/assets/js/api/APIScanner.js index 294b43fb..2b0130b0 100644 --- a/modules/scanner/assets/js/api/APIScanner.js +++ b/modules/scanner/assets/js/api/APIScanner.js @@ -120,6 +120,22 @@ export class APIScanner extends API { }); } + static async setHeadingLevel(data) { + return APIScanner.request({ + method: 'POST', + path: `${v1Prefix}/remediation/heading-level`, + data, + }); + } + + static async dismissHeadingIssue(data) { + return APIScanner.request({ + method: 'POST', + path: `${v1Prefix}/remediation/dismiss-heading-issue`, + data, + }); + } + static async clearCache(data) { return APIScanner.request({ method: 'DELETE', diff --git a/modules/scanner/assets/js/app.js b/modules/scanner/assets/js/app.js index 9e6c3eda..758eef4f 100644 --- a/modules/scanner/assets/js/app.js +++ b/modules/scanner/assets/js/app.js @@ -1,9 +1,10 @@ import ErrorBoundary from '@elementor/ui/ErrorBoundary'; +import { FocusTrap } from 'focus-trap-react'; import { Notifications } from '@ea11y/components'; import { useNotificationSettings } from '@ea11y-apps/global/hooks/use-notifications'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; import { ErrorMessage } from '@ea11y-apps/scanner/components/error-message'; -import { Header } from '@ea11y-apps/scanner/components/header'; +import Header from '@ea11y-apps/scanner/components/header'; import { Loader } from '@ea11y-apps/scanner/components/list-loader'; import { NotConnectedMessage } from '@ea11y-apps/scanner/components/not-connected-message'; import { QuotaMessage } from '@ea11y-apps/scanner/components/quota-message'; @@ -18,9 +19,10 @@ import { RemediationLayout, } from '@ea11y-apps/scanner/layouts'; import { ColorContrastLayout } from '@ea11y-apps/scanner/layouts/color-contrast-layout'; +import { HeadingStructureLayout } from '@ea11y-apps/scanner/layouts/heading-structure-layout'; import { AppContainer } from '@ea11y-apps/scanner/styles/app.styles'; import { removeExistingFocus } from '@ea11y-apps/scanner/utils/focus-on-element'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; const App = () => { const { notificationMessage, notificationType } = useNotificationSettings(); @@ -34,6 +36,7 @@ const App = () => { quotaExceeded, loading, } = useScannerWizardContext(); + const containerRef = useRef(null); const showResolvedMessage = Boolean( (resolved > 0 && violation === resolved) || violation === 0, @@ -85,21 +88,31 @@ const App = () => { return ; case BLOCKS.colorContrast: return ; + case BLOCKS.headingStructure: + return ; default: return isManage ? : ; } }; return ( - - }> -
+ + + }> +
- {showResolvedMessage && !isManage ? : getBlock()} + {showResolvedMessage && !isManage ? : getBlock()} - - - + + + + ); }; diff --git a/modules/scanner/assets/js/components/block-button/index.js b/modules/scanner/assets/js/components/block-button/index.js index 2b24ce66..f6725056 100644 --- a/modules/scanner/assets/js/components/block-button/index.js +++ b/modules/scanner/assets/js/components/block-button/index.js @@ -1,6 +1,6 @@ import CircleCheckFilledIcon from '@elementor/icons/CircleCheckFilledIcon'; -import { Chip } from '@elementor/ui'; import Box from '@elementor/ui/Box'; +import Chip from '@elementor/ui/Chip'; import Typography from '@elementor/ui/Typography'; import PropTypes from 'prop-types'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; diff --git a/modules/scanner/assets/js/components/block-button/manage-button.js b/modules/scanner/assets/js/components/block-button/manage-button.js index 0fa5d259..a36de740 100644 --- a/modules/scanner/assets/js/components/block-button/manage-button.js +++ b/modules/scanner/assets/js/components/block-button/manage-button.js @@ -22,7 +22,7 @@ export const ManageButton = ({ title, count, block }) => { mixpanelService.sendEvent(mixpanelEvents.categoryClicked, { page_url: window.ea11yScannerData?.pageData?.url, issue_count: count, - category_name: title, + category_name: block, source: 'remediation', }); }; diff --git a/modules/scanner/assets/js/components/block-button/resolve-chip.js b/modules/scanner/assets/js/components/block-button/resolve-chip.js index 99dc3e2c..22769804 100644 --- a/modules/scanner/assets/js/components/block-button/resolve-chip.js +++ b/modules/scanner/assets/js/components/block-button/resolve-chip.js @@ -1,4 +1,4 @@ -import { Chip } from '@elementor/ui'; +import Chip from '@elementor/ui/Chip'; import PropTypes from 'prop-types'; import { BLOCKS } from '@ea11y-apps/scanner/constants'; import { __ } from '@wordpress/i18n'; diff --git a/modules/scanner/assets/js/components/header/dropdown-menu.js b/modules/scanner/assets/js/components/header/dropdown-menu.js index 117505f9..2628f507 100644 --- a/modules/scanner/assets/js/components/header/dropdown-menu.js +++ b/modules/scanner/assets/js/components/header/dropdown-menu.js @@ -4,6 +4,7 @@ import DotsHorizontalIcon from '@elementor/icons/DotsHorizontalIcon'; import ExternalLinkIcon from '@elementor/icons/ExternalLinkIcon'; import RefreshIcon from '@elementor/icons/RefreshIcon'; import SettingsIcon from '@elementor/icons/SettingsIcon'; +import ThemeBuilderIcon from '@elementor/icons/ThemeBuilderIcon'; import Box from '@elementor/ui/Box'; import IconButton from '@elementor/ui/IconButton'; import Menu from '@elementor/ui/Menu'; @@ -18,12 +19,19 @@ import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner'; import { BLOCKS } from '@ea11y-apps/scanner/constants'; import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; import { DisabledMenuItemText } from '@ea11y-apps/scanner/styles/app.styles'; +import { areNoHeadingsDefined } from '@ea11y-apps/scanner/utils/page-headings'; import { useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; export const DropdownMenu = () => { - const { remediations, isManage, setOpenedBlock, setIsManage, runNewScan } = - useScannerWizardContext(); + const { + remediations, + isManage, + openedBlock, + setOpenedBlock, + setIsManage, + runNewScan, + } = useScannerWizardContext(); const { error } = useToastNotification(); const [isOpened, setIsOpened] = useState(false); const [loading, setLoading] = useState(false); @@ -68,6 +76,12 @@ export const DropdownMenu = () => { sendOnClickEvent('Manage fixes'); }; + const goToHeadingManager = () => { + handleClose(); + setIsManage(true); + setOpenedBlock(BLOCKS.headingStructure); + }; + return ( { ) : ( @@ -177,6 +191,23 @@ export const DropdownMenu = () => { )} + {!areNoHeadingsDefined() && ( + + + + + + + {__('Manage headings', 'pojo-accessibility')} + + + )} + { + const { isChanged, setOpenedBlock, isManage, setIsManage } = + useScannerWizardContext(); + + const goBack = () => { + setIsManage(false); + setOpenedBlock(BLOCKS.main); + }; + + const onClose = () => { + const widget = document.getElementById(ROOT_ID); + + closeWidget(widget); + + if (isChanged) { + void APIScanner.triggerSave({ + object_id: window?.ea11yScannerData?.pageData?.object_id, + object_type: window?.ea11yScannerData?.pageData?.object_type, + }); + } + }; + + return ( + + + + + + {isManage && ( + + + + )} + + + + + + + + + + + + + + + + + + {children} + + ); +}; + +const StyledHeaderContainer = styled(Box)` + position: sticky; + top: 0; + z-index: 2; + + overflow: visible; +`; + +const StyledTitleWrapper = styled(Box)` + display: flex; + align-items: center; + + .MuiButtonBase-root { + margin-inline-end: 8px; + } +`; + +HeaderContainer.propTypes = { + children: PropTypes.node, +}; + +export default HeaderContainer; diff --git a/modules/scanner/assets/js/components/header/index.js b/modules/scanner/assets/js/components/header/index.js index 11e43893..cd8c8b78 100644 --- a/modules/scanner/assets/js/components/header/index.js +++ b/modules/scanner/assets/js/components/header/index.js @@ -1,194 +1,81 @@ -import SettingsIcon from '@elementor/icons/SettingsIcon'; -import XIcon from '@elementor/icons/XIcon'; -import Box from '@elementor/ui/Box'; -import Card from '@elementor/ui/Card'; import Chip from '@elementor/ui/Chip'; -import IconButton from '@elementor/ui/IconButton'; -import Paper from '@elementor/ui/Paper'; import Typography from '@elementor/ui/Typography'; -import { styled } from '@elementor/ui/styles'; -import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner'; -import { Breadcrumbs } from '@ea11y-apps/scanner/components/header/breadcrumbs'; -import { DropdownMenu } from '@ea11y-apps/scanner/components/header/dropdown-menu'; -import { ManagementStats } from '@ea11y-apps/scanner/components/header/management-stats'; -import { ScanStats } from '@ea11y-apps/scanner/components/header/scan-stats'; -import { - BLOCKS, - PAGE_QUOTA_LIMIT, - ROOT_ID, -} from '@ea11y-apps/scanner/constants'; +import HeaderContainer from '@ea11y-apps/scanner/components/header/header-container'; +import Stats from '@ea11y-apps/scanner/components/header/stats'; +import Subheader from '@ea11y-apps/scanner/components/header/subheader'; +import { BLOCKS, PAGE_QUOTA_LIMIT } from '@ea11y-apps/scanner/constants'; import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; -import { Logo } from '@ea11y-apps/scanner/images'; +import useScannerSettings from '@ea11y-apps/scanner/hooks/use-scanner-settings'; import { - HeaderCard, - HeaderContent, + StyledStatsBlock, TitleBox, } from '@ea11y-apps/scanner/styles/app.styles'; -import { closeWidget } from '@ea11y-apps/scanner/utils/close-widget'; import { __ } from '@wordpress/i18n'; -export const Header = () => { +const Header = () => { const { openedBlock, results, loading, isError, - isManage, - isChanged, - setOpenedBlock, - setIsManage, - violation, + violation: violationsCount, } = useScannerWizardContext(); + const { pageData, isConnected } = useScannerSettings(); - const onClose = () => { - if (isManage) { - setIsManage(false); - setOpenedBlock(BLOCKS.main); - } else { - const widget = document.getElementById(ROOT_ID); - closeWidget(widget); - if (isChanged) { - void APIScanner.triggerSave({ - object_id: window?.ea11yScannerData?.pageData?.object_id, - object_type: window?.ea11yScannerData?.pageData?.object_type, - }); - } - } - }; - - const showChip = + const showViolationsChip = PAGE_QUOTA_LIMIT && !isError && !loading && openedBlock === BLOCKS.main && - violation > 0; + violationsCount > 0; - const showMainBlock = - window.ea11yScannerData?.isConnected && - !isError && - PAGE_QUOTA_LIMIT && - (violation > 0 || loading); + const showShortHeader = + !isConnected || + isError || + !PAGE_QUOTA_LIMIT || + (!violationsCount && !loading); - const isMainHeader = + const showStatsBlock = openedBlock === BLOCKS.main || openedBlock === BLOCKS.management; - const headerData = () => { - switch (openedBlock) { - case BLOCKS.main: - return ; - case BLOCKS.management: - return ; - default: - return ; - } - }; - - const content = ( - <> - {isMainHeader && ( - - - {window?.ea11yScannerData?.pageData?.title} - - - {showChip && ( - - )} - - )} - - {headerData()} - - ); + if (showShortHeader) { + return ; + } - return ( - - - - + + - - {isManage ? ( - <> - - - - {__('Manage fixes', 'pojo-accessibility')} - - - ) : ( - <> - - - - {__('Accessibility Assistant', 'pojo-accessibility')} - - - - - )} - - - - - - - - - - - + + {pageData.title} + + + {showViolationsChip && ( + + )} + + + + + + ); + } - {showMainBlock && ( - - {isMainHeader ? ( - - {content} - - ) : ( - content - )} - - )} - + return ( + + + ); }; -const StyledCard = styled(Card)` - position: sticky; - top: 0; - z-index: 2; - overflow: visible; -`; - -const StyledTitle = styled(Typography)` - font-size: 16px; - font-weight: 500; - line-height: 130%; - letter-spacing: 0.15px; - margin: 0; - - .MuiChip-root { - margin-inline-start: ${({ theme }) => theme.spacing(1)}; - - font-weight: 400; - } -`; +export default Header; diff --git a/modules/scanner/assets/js/components/header/stats/index.js b/modules/scanner/assets/js/components/header/stats/index.js new file mode 100644 index 00000000..908b1460 --- /dev/null +++ b/modules/scanner/assets/js/components/header/stats/index.js @@ -0,0 +1,19 @@ +import { ManagementStats } from '@ea11y-apps/scanner/components/header/stats/management-stats'; +import { ScanStats } from '@ea11y-apps/scanner/components/header/stats/scan-stats'; +import { BLOCKS } from '@ea11y-apps/scanner/constants'; +import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; + +const Stats = () => { + const { openedBlock } = useScannerWizardContext(); + + switch (openedBlock) { + case BLOCKS.main: + return ; + case BLOCKS.management: + return ; + default: + return false; + } +}; + +export default Stats; diff --git a/modules/scanner/assets/js/components/header/management-stats.js b/modules/scanner/assets/js/components/header/stats/management-stats.js similarity index 100% rename from modules/scanner/assets/js/components/header/management-stats.js rename to modules/scanner/assets/js/components/header/stats/management-stats.js diff --git a/modules/scanner/assets/js/components/header/scan-stats.js b/modules/scanner/assets/js/components/header/stats/scan-stats.js similarity index 78% rename from modules/scanner/assets/js/components/header/scan-stats.js rename to modules/scanner/assets/js/components/header/stats/scan-stats.js index 536bcdf9..25436af9 100644 --- a/modules/scanner/assets/js/components/header/scan-stats.js +++ b/modules/scanner/assets/js/components/header/stats/scan-stats.js @@ -4,6 +4,7 @@ import Button from '@elementor/ui/Button'; import LinearProgress from '@elementor/ui/LinearProgress'; import Typography from '@elementor/ui/Typography'; import { styled } from '@elementor/ui/styles'; +import { injectTemplateVars } from '@ea11y-apps/global/utils/inject-template-vars'; import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; import { StyledSkeleton } from '@ea11y-apps/scanner/styles/app.styles'; import { __, sprintf } from '@wordpress/i18n'; @@ -14,6 +15,12 @@ export const ScanStats = () => { const percent = violation !== 0 ? Math.min((resolved / violation) * 100, 100) : 100; const displayPercent = results ? Math.round(percent) : 0; + const statsText = sprintf( + // Translators: %1$s - resolved, %2$s - percent + __('%1$s Fixed {{lightGrey}}(%2$s%%){{/lightGrey}}', 'pojo-accessibility'), + resolved, + displayPercent, + ); return ( @@ -25,20 +32,22 @@ export const ScanStats = () => { ) : ( <> - {sprintf( - // Translators: %1$s - resolved, %2$s - percent, %3$s - % - __('%1$s Fixed (%2$s%3$s)', 'pojo-accessibility'), - resolved, - displayPercent, - '%', - )} + {injectTemplateVars(statsText, { + lightGrey: ({ children }) => ( + + {children} + + ), + })} + + + + + + ); +}; + +HeadingTreeListItemActions.propTypes = { + status: PropTypes.oneOf(Object.values(HEADING_STATUS)).isRequired, + node: PropTypes.object.isRequired, + isDisabled: PropTypes.bool.isRequired, + isDismiss: PropTypes.bool.isRequired, + setIsDismiss: PropTypes.func.isRequired, + setIsSubmitted: PropTypes.func.isRequired, + displayLevel: PropTypes.string.isRequired, + violation: PropTypes.oneOf(Object.values(EA11Y_RULES)), + isExpanded: PropTypes.bool.isRequired, +}; + +export default HeadingTreeListItemActions; diff --git a/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/alert.js b/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/alert.js new file mode 100644 index 00000000..cb477300 --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/alert.js @@ -0,0 +1,26 @@ +import AlertTitle from '@elementor/ui/AlertTitle'; +import Typography from '@elementor/ui/Typography'; +import PropTypes from 'prop-types'; +import { EA11Y_RULES } from '@ea11y-apps/scanner/rules'; +import { StyledListItemAlert } from '@ea11y-apps/scanner/styles/heading-structure.styles'; +import { HEADING_STATUS } from '@ea11y-apps/scanner/types/heading'; +import { VIOLATION_PARAMS } from '../constants'; + +const HeadingTreeListItemAlert = ({ status, violation }) => { + return ( + + {VIOLATION_PARAMS[violation]?.title} + + + {VIOLATION_PARAMS[violation]?.description} + + + ); +}; + +HeadingTreeListItemAlert.propTypes = { + status: PropTypes.oneOf(Object.values(HEADING_STATUS)).isRequired, + violation: PropTypes.oneOf(Object.values(EA11Y_RULES)).isRequired, +}; + +export default HeadingTreeListItemAlert; diff --git a/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/index.js b/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/index.js new file mode 100644 index 00000000..34ae2c67 --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/index.js @@ -0,0 +1,179 @@ +import Collapse from '@elementor/ui/Collapse'; +import PropTypes from 'prop-types'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import HeadingTreeListItemActions from '@ea11y-apps/scanner/components/heading-structure/heading-tree-list-item/actions'; +import HeadingTreeListItemAlert from '@ea11y-apps/scanner/components/heading-structure/heading-tree-list-item/alert'; +import HeadingTreeListItemTopWrapper from '@ea11y-apps/scanner/components/heading-structure/heading-tree-list-item/top-wrapper'; +import { useHeadingStructureContext } from '@ea11y-apps/scanner/context/heading-structure-context'; +import { useHeadingNodeManipulation } from '@ea11y-apps/scanner/hooks/use-heading-node-manipulation'; +import { EA11Y_RULES } from '@ea11y-apps/scanner/rules'; +import { + StyledListItemBottomWrapper, + StyledListItemDetails, + StyledListItemSelect, + StyledTreeListItem, +} from '@ea11y-apps/scanner/styles/heading-structure.styles'; +import { HEADING_STATUS } from '@ea11y-apps/scanner/types/heading'; +import { memo, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { HEADING_OPTIONS } from '../constants'; + +const HeadingStructureHeadingTreeListItemBase = ({ + id, + level, + content, + node, + status, + violation = null, + isExpanded, + toggleHeading, +}) => { + const { isLoading, updateHeadingsTree } = useHeadingStructureContext(); + + const { + applyNewLevel, + hasDraft, + getDraftLevelForDisplay, + restoreOriginalAttributes, + } = useHeadingNodeManipulation({ node }); + + const isDraft = hasDraft(node); + const displayLevel = isDraft ? getDraftLevelForDisplay() : `h${level}`; + const [isDismiss, setIsDismiss] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + + const isApplyControlDisabled = + (HEADING_STATUS.WARNING === status && !isDismiss) || + (!isDraft && !isDismiss) || + isLoading; + + const onLevelChange = (e) => { + mixpanelService.sendEvent(mixpanelEvents.headingSelected, { + previous_heading: displayLevel, + new_heading: e.target.value.toUpperCase(), + }); + + applyNewLevel(e.target.value); + setIsSubmitted(false); + + updateHeadingsTree(); + }; + + const applyExpandedStyles = () => { + node.style.boxShadow = '0 0 0 8px #2563EB'; + node.style.borderRadius = '4px'; + node.style.transition = '300ms ease-in-out'; + + node.scrollIntoView({ + behavior: 'smooth', + }); + }; + + const removeExpandedStyles = () => { + node.style.boxShadow = ''; + node.style.borderRadius = ''; + node.style.transition = ''; + }; + + useEffect(() => { + if (isExpanded) { + applyExpandedStyles(); + } else { + removeExpandedStyles(); + if (!isSubmitted) { + restoreOriginalAttributes(); + setTimeout(() => updateHeadingsTree(), 0); + setIsSubmitted(false); + } + } + }, [isExpanded]); + + return ( + + + + + + + + {HEADING_OPTIONS} + + + {(HEADING_STATUS.ERROR === status || + (HEADING_STATUS.WARNING === status && !isDismiss)) && + violation && ( + + )} + + + + + + + ); +}; + +const HeadingStructureHeadingTreeListItem = memo( + HeadingStructureHeadingTreeListItemBase, + (prev, next) => + prev.id === next.id && + prev.level === next.level && + prev.content === next.content && + prev.node === next.node && + prev.status === next.status && + prev.violation === next.violation && + prev.isExpanded === next.isExpanded, +); + +HeadingStructureHeadingTreeListItem.propTypes = { + id: PropTypes.string.isRequired, + level: PropTypes.number.isRequired, + content: PropTypes.string.isRequired, + node: PropTypes.object.isRequired, + status: PropTypes.oneOf(Object.values(HEADING_STATUS)).isRequired, + violation: PropTypes.oneOf(Object.values(EA11Y_RULES)), + isExpanded: PropTypes.bool.isRequired, + toggleHeading: PropTypes.func.isRequired, +}; + +export default HeadingStructureHeadingTreeListItem; diff --git a/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/top-wrapper.js b/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/top-wrapper.js new file mode 100644 index 00000000..0455e675 --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/heading-tree-list-item/top-wrapper.js @@ -0,0 +1,77 @@ +import Typography from '@elementor/ui/Typography'; +import PropTypes from 'prop-types'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { + StyledListItemContent, + StyledListItemLevelBox, + StyledListItemTopWrapper, +} from '@ea11y-apps/scanner/styles/heading-structure.styles'; +import { + HEADING_STATUS, + HEADING_STATUS_DESCRIPTION, +} from '@ea11y-apps/scanner/types/heading'; +import { keyForNode } from '@ea11y-apps/scanner/utils/page-headings'; +import { STATUS_CONFIG } from '../constants'; + +const HeadingTreeListItemTopWrapper = ({ + id, + status, + isDismiss, + level, + node, + displayLevel, + content, + isExpanded, + toggleHeading, +}) => { + const visualStatus = isDismiss ? HEADING_STATUS.SUCCESS : status; + const config = STATUS_CONFIG[visualStatus]; + const IconComponent = config.icon; + + const onHeadingClick = () => { + if (!isExpanded) { + mixpanelService.sendEvent(mixpanelEvents.headingClicked, { + level: displayLevel, + location: keyForNode(node).replace('heading-tree-', ''), + }); + } + + toggleHeading(); + }; + + return ( + + + + {displayLevel.toUpperCase()} + + + + + {content} + + + + + ); +}; + +HeadingTreeListItemTopWrapper.propTypes = { + status: PropTypes.oneOf(Object.values(HEADING_STATUS)).isRequired, + isDismiss: PropTypes.bool.isRequired, + level: PropTypes.number.isRequired, + displayLevel: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + isExpanded: PropTypes.bool.isRequired, + toggleHeading: PropTypes.func.isRequired, +}; + +export default HeadingTreeListItemTopWrapper; diff --git a/modules/scanner/assets/js/components/heading-structure/heading-tree-list.js b/modules/scanner/assets/js/components/heading-structure/heading-tree-list.js new file mode 100644 index 00000000..7e9bb7a3 --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/heading-tree-list.js @@ -0,0 +1,12 @@ +import PropTypes from 'prop-types'; +import { StyledTreeList } from '@ea11y-apps/scanner/styles/heading-structure.styles'; + +const HeadingStructureHeadingTreeList = ({ children }) => { + return {children}; +}; + +HeadingStructureHeadingTreeList.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default HeadingStructureHeadingTreeList; diff --git a/modules/scanner/assets/js/components/heading-structure/heading-tree-loader.js b/modules/scanner/assets/js/components/heading-structure/heading-tree-loader.js new file mode 100644 index 00000000..b440fc33 --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/heading-tree-loader.js @@ -0,0 +1,28 @@ +import Box from '@elementor/ui/Box'; +import { styled } from '@elementor/ui/styles'; +import { StyledSkeleton } from '@ea11y-apps/scanner/styles/app.styles'; +import { memo } from '@wordpress/element'; + +const HeadingStructureHeadingTreeLoader = memo(() => { + return ( + + + + + + + + + + ); +}); + +const StyledLoaderContainer = styled(Box)` + display: flex; + flex-direction: column; + + margin-top: ${({ theme }) => theme.spacing(2)}; + gap: ${({ theme }) => theme.spacing(1.5)}; +`; + +export default HeadingStructureHeadingTreeLoader; diff --git a/modules/scanner/assets/js/components/heading-structure/heading-tree.js b/modules/scanner/assets/js/components/heading-structure/heading-tree.js new file mode 100644 index 00000000..07612a4c --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/heading-tree.js @@ -0,0 +1,72 @@ +import HeadingStructureHeadingTreeList from '@ea11y-apps/scanner/components/heading-structure/heading-tree-list'; +import HeadingStructureHeadingTreeListItem from '@ea11y-apps/scanner/components/heading-structure/heading-tree-list-item'; +import HeadingStructureHeadingTreeLoader from '@ea11y-apps/scanner/components/heading-structure/heading-tree-loader'; +import { useHeadingStructureContext } from '@ea11y-apps/scanner/context/heading-structure-context'; +import { keyForNode } from '@ea11y-apps/scanner/utils/page-headings'; +import { useEffect, useCallback } from '@wordpress/element'; + +const HeadingStructureHeadingTree = () => { + const { + isLoading, + expandedKey, + pageHeadings, + updateHeadingsTree, + toggleHeading, + } = useHeadingStructureContext(); + + useEffect(() => { + updateHeadingsTree(); + }, []); + + /** + * @param {import('../../types/heading').Ea11yHeading[]} headings + * @param {number | false} nestedId + * @return {JSX.Element} React element to render. + */ + const renderPageHeadings = useCallback( + (headings, nestedId) => { + const children = headings.flatMap((heading, i) => { + const key = keyForNode(heading.node); + + const item = ( + toggleHeading(heading.node)} + /> + ); + + if (heading.children.length) { + const subHeaders = renderPageHeadings(heading.children, i); + + return [item, subHeaders]; + } + + return [item]; + }); + + const list = ; + + if (false !== nestedId) { + return
  • {list}
  • ; + } + + return list; + }, + [expandedKey, toggleHeading, keyForNode], + ); + + if (!pageHeadings.length && isLoading) { + return ; + } + + return renderPageHeadings(pageHeadings, false); +}; + +export default HeadingStructureHeadingTree; diff --git a/modules/scanner/assets/js/components/heading-structure/help-text.js b/modules/scanner/assets/js/components/heading-structure/help-text.js new file mode 100644 index 00000000..f6e3f05f --- /dev/null +++ b/modules/scanner/assets/js/components/heading-structure/help-text.js @@ -0,0 +1,31 @@ +import { injectTemplateVars } from '@ea11y-apps/global/utils/inject-template-vars'; +import { StyledDescription } from '@ea11y-apps/scanner/styles/heading-structure.styles'; +import { memo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +const HeadingStructureHelpText = memo(() => { + return ( + <> + + {__( + 'Make sure the order of your headings makes sense, so content is read in the right order.', + 'pojo-accessibility', + )} + + + + {injectTemplateVars( + __( + '{{bold}}Note:{{/bold}} Changing a heading’s level won’t affect how it looks.', + 'pojo-accessibility', + ), + { + bold: ({ children }) => {children}, + }, + )} + + + ); +}); + +export default HeadingStructureHelpText; diff --git a/modules/scanner/assets/js/components/main-list/index.js b/modules/scanner/assets/js/components/main-list/index.js index 9c1ceb81..2368677c 100644 --- a/modules/scanner/assets/js/components/main-list/index.js +++ b/modules/scanner/assets/js/components/main-list/index.js @@ -4,7 +4,7 @@ import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wiz import { StyledBlockButtonsBox } from '@ea11y-apps/scanner/styles/app.styles'; export const MainList = () => { - const { sortedViolations, altTextData, manualData } = + const { sortedViolations, altTextData, manualData, getHeadingsStats } = useScannerWizardContext(); const scannerFixExist = sortedViolations.altText.length > 0; @@ -24,11 +24,16 @@ export const MainList = () => { const resolved = itemsData?.filter((item) => item?.resolved === true).length || 0; + const count = + key === BLOCKS.headingStructure + ? getHeadingsStats().error + : sortedViolations[key].length - resolved; + return ( ); diff --git a/modules/scanner/assets/js/constants/index.js b/modules/scanner/assets/js/constants/index.js index e3980de2..c1c0d01b 100644 --- a/modules/scanner/assets/js/constants/index.js +++ b/modules/scanner/assets/js/constants/index.js @@ -1,8 +1,8 @@ import { __ } from '@wordpress/i18n'; -export const TOP_BAR_LINK = '#wp-admin-bar-ea11y-scanner-wizard a'; -export const SCAN_LINK = '#wp-admin-bar-ea11y-scan-page a'; -export const CLEAR_CACHE_LINK = '#wp-admin-bar-ea11y-clear-cache a'; +export const TOP_BAR_LINK = '#wp-admin-bar-ea11y-scanner-wizard > a'; +export const SCAN_LINK = '#wp-admin-bar-ea11y-scan-page > a'; +export const CLEAR_CACHE_LINK = '#wp-admin-bar-ea11y-clear-cache > a'; export const SCANNER_URL_PARAM = 'open-ea11y-assistant'; export const MANAGE_URL_PARAM = 'open-ea11y-manage'; @@ -15,6 +15,11 @@ export const COLOR_CONTRAST_SELECTORS_COUNT = 5; export const DATA_INITIAL_BG = 'data-initial-bg'; export const DATA_INITIAL_COLOR = 'data-initial-color'; +export const LEVEL_VIOLATION = 'violation'; +export const LEVEL_POTENTIAL = 'potentialViolation'; +export const RULE_TEXT_CONTRAST = 'text_contrast_sufficient'; +export const RATIO_EXCLUDED = 1; + export const UPGRADE_URL = 'https://go.elementor.com/acc-free-no-AI-scanner'; 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'; @@ -45,6 +50,7 @@ export const BLOCKS = { formsInputsError: 'formsInputsError', keyboardAssistiveTech: 'keyboardAssistiveTech', pageStructureNav: 'pageStructureNav', + headingStructure: 'headingStructure', tables: 'tables', colorContrast: 'colorContrast', other: 'other', @@ -55,6 +61,7 @@ export const MANUAL_GROUPS = { formsInputsError: [], keyboardAssistiveTech: [], pageStructureNav: [], + headingStructure: [], tables: [], colorContrast: [], other: [], @@ -69,6 +76,7 @@ export const BLOCK_TITLES = { 'pojo-accessibility', ), pageStructureNav: __('Page Structure & Navigation', 'pojo-accessibility'), + headingStructure: __('Heading Structure', 'pojo-accessibility'), tables: __('Tables', 'pojo-accessibility'), colorContrast: __('Color contrast', 'pojo-accessibility'), other: __('Other Accessibility Issues', 'pojo-accessibility'), @@ -95,6 +103,10 @@ export const BLOCK_INFO = { 'Use headings and clear structure to help people easily navigate your content.', 'pojo-accessibility', ), + headingStructure: __( + 'Headings help screen reader users and search engines read pages in the right order. The structure should be organized and each heading given a level.', + 'pojo-accessibility', + ), tables: __( 'Give tables clear headers and captions so everyone can easily understand the data.', 'pojo-accessibility', @@ -115,6 +127,7 @@ export const INITIAL_SORTED_VIOLATIONS = { formsInputsError: [], keyboardAssistiveTech: [], pageStructureNav: [], + headingStructure: [], tables: [], colorContrast: [], other: [], @@ -184,6 +197,11 @@ export const VIOLATION_TYPES = { 'input_haspopup_conflict', 'element_tabbable_role_valid', ], + headingStructure: [ + 'single_h1_check', + 'missing_h1_check', + 'heading_hierarchy_check', + ], pageStructureNav: [ 'table_headers_ref_valid', 'table_scope_valid', diff --git a/modules/scanner/assets/js/context/heading-structure-context.js b/modules/scanner/assets/js/context/heading-structure-context.js new file mode 100644 index 00000000..a12e1c37 --- /dev/null +++ b/modules/scanner/assets/js/context/heading-structure-context.js @@ -0,0 +1,161 @@ +import PropTypes from 'prop-types'; +import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner'; +import useScannerSettings from '@ea11y-apps/scanner/hooks/use-scanner-settings'; +import { + getHeadingXpath, + getPageHeadingsTree, + keyForNode, +} from '@ea11y-apps/scanner/utils/page-headings'; +import { + calculateStats, + validateHeadings, +} from '@ea11y-apps/scanner/utils/validate-headings'; +import { createContext, useContext, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useScannerWizardContext } from './scanner-wizard-context'; + +export const HeadingStructureContext = createContext({}); + +export const HeadingStructureContextProvider = ({ children }) => { + /** Important to keep it true by default. */ + const loadingState = useState(true); + const errorState = useState(''); + const pageHeadingsState = useState([]); + const pageHeadingValidationStatsState = useState({}); + const expandedKeyState = useState(null); + + return ( + + {children} + + ); +}; + +HeadingStructureContextProvider.propTypes = { + children: PropTypes.node, +}; + +export const useHeadingStructureContext = () => { + const { + loadingState, + errorState, + pageHeadingsState, + pageHeadingValidationStatsState, + expandedKeyState, + } = useContext(HeadingStructureContext); + const { currentScanId } = useScannerWizardContext(); + const { dismissedHeadingIssues, pageData } = useScannerSettings(); + const [isLoading, setIsLoading] = loadingState; + const [error, setError] = errorState; + const [pageHeadings, setPageHeadings] = pageHeadingsState; + const [validationStats, setValidationStats] = pageHeadingValidationStatsState; + const [expandedKey, setExpandedKey] = expandedKeyState; + + const updateHeadingsTree = () => { + const updatedHeadings = validateHeadings( + getPageHeadingsTree(), + dismissedHeadingIssues, + ); + + setPageHeadings(updatedHeadings); + setValidationStats(calculateStats(updatedHeadings)); + + setIsLoading(false); + }; + + const onHeadingWarningDismiss = async ({ node }) => { + setIsLoading(true); + setError(''); + + try { + const xpath = getHeadingXpath(node); + + if (!node) { + throw new TypeError(); + } + + await APIScanner.dismissHeadingIssue({ + pageId: pageData.object_id, + xpath, + }); + + window.ea11yScannerData.dismissedHeadingIssues.push(xpath); + + await APIScanner.resolveIssue(currentScanId); + + return true; + } catch (e) { + console.error('onHeadingWarningDismiss(): ', e); + setError(__('An error occurred.', 'pojo-accessibility')); + + return false; + } + }; + + const onHeadingLevelUpdate = async ({ node, newLevel, violation }) => { + setIsLoading(true); + setError(''); + + try { + const xpath = getHeadingXpath(node); + + if (!node || !pageData.url || !newLevel) { + throw new TypeError(); + } + + await APIScanner.setHeadingLevel({ + url: pageData.url, + level: newLevel, + xpath, + rule: violation, + }); + + await APIScanner.resolveIssue(currentScanId); + setIsLoading(false); + + return true; + } catch (e) { + console.error('onHeadingLevelUpdate(): ', e); + setError(__('An error occurred.', 'pojo-accessibility')); + setIsLoading(false); + + return false; + } + }; + + const isHeadingExpanded = (node) => { + return keyForNode(node) === expandedKey; + }; + + const toggleHeading = (node) => { + const key = keyForNode(node); + setExpandedKey((prev) => (prev === key ? null : key)); + }; + + const collapseHeading = () => { + setExpandedKey(null); + }; + + return { + isLoading, + error, + pageHeadings, + validationStats, + setPageHeadings, + expandedKey, + updateHeadingsTree, + onHeadingWarningDismiss, + onHeadingLevelUpdate, + isHeadingExpanded, + toggleHeading, + collapseHeading, + }; +}; diff --git a/modules/scanner/assets/js/context/scanner-wizard-context.js b/modules/scanner/assets/js/context/scanner-wizard-context.js index ea5b68c2..9497b3e8 100644 --- a/modules/scanner/assets/js/context/scanner-wizard-context.js +++ b/modules/scanner/assets/js/context/scanner-wizard-context.js @@ -3,19 +3,30 @@ import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner'; import { BLOCKS, INITIAL_SORTED_VIOLATIONS, + LEVEL_POTENTIAL, + LEVEL_VIOLATION, MANAGE_URL_PARAM, MANUAL_GROUPS, + RATIO_EXCLUDED, + RULE_TEXT_CONTRAST, } from '@ea11y-apps/scanner/constants'; +import useScannerSettings from '@ea11y-apps/scanner/hooks/use-scanner-settings'; +import { runAllEa11yRules } from '@ea11y-apps/scanner/rules'; import { scannerWizard } from '@ea11y-apps/scanner/services/scanner-wizard'; import { focusOnElement, removeExistingFocus, } from '@ea11y-apps/scanner/utils/focus-on-element'; import { getElementByXPath } from '@ea11y-apps/scanner/utils/get-element-by-xpath'; +import { getPageHeadingsTree } from '@ea11y-apps/scanner/utils/page-headings'; import { sortRemediation, sortViolations, } from '@ea11y-apps/scanner/utils/sort-violations'; +import { + calculateStats, + validateHeadings, +} from '@ea11y-apps/scanner/utils/validate-headings'; import { createContext, useContext, @@ -59,6 +70,8 @@ export const ScannerWizardContext = createContext({ }); export const ScannerWizardContextProvider = ({ children }) => { + const { dismissedHeadingIssues } = useScannerSettings(); + const [results, setResults] = useState(); const [remediations, setRemediations] = useState([]); const [sortedViolations, setSortedViolations] = useState( @@ -196,24 +209,49 @@ export const ScannerWizardContextProvider = ({ children }) => { } }; + const isViolation = (item) => item.level === LEVEL_VIOLATION; + const isContrastViolation = (item) => + item.ruleId === RULE_TEXT_CONTRAST && + item.level === LEVEL_POTENTIAL && + Number(item.messageArgs[0]) !== RATIO_EXCLUDED; + const getResults = async () => { setLoading(true); try { const url = new URL(window.location.href); const data = await window.ace.check(document); + const filtered = data.results.filter( + (item) => isViolation(item) || isContrastViolation(item), + ); + + const customResults = runAllEa11yRules(document); + + const filteredCustomResults = customResults.filter( (item) => - item.level === 'violation' || - (item.ruleId === 'text_contrast_sufficient' && - item.level === 'potentialViolation'), + item.level === 'violation' && + !dismissedHeadingIssues.includes(item.path.dom), ); - const sorted = sortViolations(filtered); + + const allResults = [...filtered, ...filteredCustomResults]; + + data.results = allResults; if (data?.summary?.counts) { data.summary.counts.issuesResolved = 0; + data.summary.counts.violation += filteredCustomResults.filter( + (item) => item.level === 'violation', + ).length; + data.summary.counts.recommendation = + (data.summary.counts.recommendation || 0) + + filteredCustomResults.filter( + (item) => item.level === 'recommendation', + ).length; } + const sorted = sortViolations(allResults); + await registerPage(data, sorted); await addScanResults(data); @@ -266,15 +304,25 @@ export const ScannerWizardContextProvider = ({ children }) => { }, (_, i) => i, ); - return block === BLOCKS.altText - ? (altTextData?.length === sortedViolations[block]?.length && - indexes.every((index) => index in altTextData) && - altTextData.every((data) => data?.resolved)) || + switch (block) { + case BLOCKS.altText: + return ( + (altTextData?.length === sortedViolations[block]?.length && + indexes.every((index) => index in altTextData) && + altTextData.every((data) => data?.resolved)) || + sortedViolations[block]?.length === 0 + ); + case BLOCKS.headingStructure: + const stats = getHeadingsStats(); + return stats.error === 0; + default: + return ( + (manualData[block]?.length === sortedViolations[block]?.length && + indexes.every((index) => index in manualData[block]) && + manualData[block].every((data) => data?.resolved)) || sortedViolations[block]?.length === 0 - : (manualData[block]?.length === sortedViolations[block]?.length && - indexes.every((index) => index in manualData[block]) && - manualData[block].every((data) => data?.resolved)) || - sortedViolations[block]?.length === 0; + ); + } }; const isChanged = @@ -294,6 +342,14 @@ export const ScannerWizardContextProvider = ({ children }) => { window.location.assign(url); }; + const getHeadingsStats = () => { + const updatedHeadings = validateHeadings( + getPageHeadingsTree(), + dismissedHeadingIssues, + ); + return calculateStats(updatedHeadings); + }; + return ( { isResolved, handleOpen, runNewScan, + getHeadingsStats, }} > {children} diff --git a/modules/scanner/assets/js/hooks/use-heading-node-manipulation.js b/modules/scanner/assets/js/hooks/use-heading-node-manipulation.js new file mode 100644 index 00000000..68c885cb --- /dev/null +++ b/modules/scanner/assets/js/hooks/use-heading-node-manipulation.js @@ -0,0 +1,108 @@ +export const useHeadingNodeManipulation = ({ node }) => { + const hasDraft = () => { + return node.getAttribute('data-ea11y-scanner-heading-draft'); + }; + + const getOriginalHeadingLevel = () => { + const originalLevel = node.getAttribute( + 'data-ea11y-scanner-original-level', + ); + + if (originalLevel) { + return parseInt(originalLevel, 10); + } + + return null; + }; + + const getDraftLevelForDisplay = () => { + if (!hasDraft()) { + return ''; + } + + const role = node.getAttribute('role'); + + if ('none' === role) { + return 'p'; + } + + const ariaLevel = node.getAttribute('aria-level'); + + return `h${ariaLevel}`; + }; + + const backupOriginalAttributes = () => { + if (hasDraft()) { + return; + } + + const role = node.getAttribute('role'); + const ariaLevel = node.getAttribute('aria-level'); + + node.setAttribute('data-ea11y-scanner-heading-draft', 'true'); + node.setAttribute('data-ea11y-scanner-original-role', role || ''); + node.setAttribute('data-ea11y-scanner-original-level', ariaLevel || ''); + }; + + const restoreOriginalAttributes = () => { + if (!hasDraft()) { + return; + } + + const originalRole = node.getAttribute('data-ea11y-scanner-original-role'); + const originalAriaLevel = node.getAttribute( + 'data-ea11y-scanner-original-level', + ); + + if (originalRole) { + node.setAttribute('role', originalRole); + } else { + node.removeAttribute('role'); + } + + if (originalAriaLevel) { + node.setAttribute('aria-level', originalAriaLevel); + } else { + node.removeAttribute('aria-level'); + } + + node.removeAttribute('data-ea11y-scanner-original-role'); + node.removeAttribute('data-ea11y-scanner-original-level'); + node.removeAttribute('data-ea11y-scanner-heading-draft'); + }; + + const clearOriginalAttributes = () => { + node.removeAttribute('data-ea11y-scanner-original-role'); + node.removeAttribute('data-ea11y-scanner-original-level'); + node.removeAttribute('data-ea11y-scanner-heading-draft'); + }; + + const applyNewLevel = (value) => { + backupOriginalAttributes(); + + if (value === 'p') { + /** + * Don't change this one to 'presentation'! + * + * Here it must be 'none' to allow preview validation to happen without the element + * to be removed from the tree. We also don't want to stop applying the attribute, so the user can + * see if the layout changes in case of selectors like [role="banner"]. + */ + node.setAttribute('role', 'none'); + node.removeAttribute('aria-level'); + } else { + node.setAttribute('role', 'heading'); + node.setAttribute('aria-level', value[1]); + } + }; + + return { + hasDraft, + getOriginalHeadingLevel, + getDraftLevelForDisplay, + backupOriginalAttributes, + restoreOriginalAttributes, + clearOriginalAttributes, + applyNewLevel, + }; +}; diff --git a/modules/scanner/assets/js/hooks/use-manual-fix-form.js b/modules/scanner/assets/js/hooks/use-manual-fix-form.js index 19baa802..2e5f2ca9 100644 --- a/modules/scanner/assets/js/hooks/use-manual-fix-form.js +++ b/modules/scanner/assets/js/hooks/use-manual-fix-form.js @@ -6,6 +6,7 @@ import { BLOCKS } from '@ea11y-apps/scanner/constants'; 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'; +import { getElementContext } from '@ea11y-apps/scanner/utils/get-element-context'; import { useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -51,9 +52,11 @@ export const useManualFixForm = ({ item, current }) => { const getAISuggestion = async () => { try { setAiResponseLoading(true); + const context = getElementContext(item.node); const result = await APIScanner.resolveWithAI({ snippet: item.snippet, violation: item.ruleId, + context, }); updateData({ aiSuggestion: result.data.response, diff --git a/modules/scanner/assets/js/hooks/use-scanner-settings.js b/modules/scanner/assets/js/hooks/use-scanner-settings.js new file mode 100644 index 00000000..4f7064bd --- /dev/null +++ b/modules/scanner/assets/js/hooks/use-scanner-settings.js @@ -0,0 +1,58 @@ +import * as z from 'zod'; + +const ScannerSettings = z.object({ + wpRestNonce: z.string(), + dashboardUrl: z.url(), + scannerUrl: z.url(), + initialScanResult: z.object({ + url: z.url(), + counts: z.object({ + issuesResolved: z.int(), + manual: z.int(), + pass: z.int(), + potentialRecommendation: z.int(), + potentialViolation: z.int(), + recommendation: z.int(), + violation: z.int(), + }), + }), + pageData: z.object({ + entry_id: z.string(), + object_id: z.int(), + object_type: z.string(), + object_type_name: z.string(), + title: z.string(), + unregistered: z.boolean(), + url: z.url(), + }), + dismissedHeadingIssues: z.array(z.string()), + isConnected: z.string(), + isRTL: z.string(), +}); + +const useScannerSettings = () => { + const validationResult = ScannerSettings.safeParse(window.ea11yScannerData); + + if (!validationResult.success) { + console.error( + 'Ea11y scanner: Validation error of `window.ea11yScannerData`', + validationResult.error.issues, + ); + } + + if (!window.ea11yScannerData) { + window.ea11yScannerData = {}; + } + + if (!window.ea11yScannerData.dismissedHeadingIssues) { + window.ea11yScannerData.dismissedHeadingIssues = []; + } + + if (!window.ea11yScannerData.pageData) { + window.ea11yScannerData.pageData = {}; + } + + return window.ea11yScannerData; +}; + +export default useScannerSettings; diff --git a/modules/scanner/assets/js/images/theme-builder-icon.js b/modules/scanner/assets/js/images/theme-builder-icon.js new file mode 100644 index 00000000..b946176c --- /dev/null +++ b/modules/scanner/assets/js/images/theme-builder-icon.js @@ -0,0 +1,13 @@ +export const ThemeBuilderIcon = () => { + return ( + + + + ); +}; diff --git a/modules/scanner/assets/js/index.js b/modules/scanner/assets/js/index.js index b4b85d62..3eb363de 100644 --- a/modules/scanner/assets/js/index.js +++ b/modules/scanner/assets/js/index.js @@ -16,6 +16,7 @@ import { SCANNER_URL_PARAM, TOP_BAR_LINK, } from '@ea11y-apps/scanner/constants'; +import { HeadingStructureContextProvider } from '@ea11y-apps/scanner/context/heading-structure-context'; import { ScannerWizardContextProvider } from '@ea11y-apps/scanner/context/scanner-wizard-context'; import { closeWidget } from '@ea11y-apps/scanner/utils/close-widget'; import { createRoot, Fragment, StrictMode } from '@wordpress/element'; @@ -27,6 +28,7 @@ document.addEventListener('DOMContentLoaded', function () { document .querySelector(CLEAR_CACHE_LINK) ?.addEventListener('click', async (event) => { + event.stopPropagation(); event.preventDefault(); try { await APIScanner.clearCache(); @@ -110,7 +112,9 @@ const initApp = () => { - + + + diff --git a/modules/scanner/assets/js/layouts/heading-structure-layout.js b/modules/scanner/assets/js/layouts/heading-structure-layout.js new file mode 100644 index 00000000..634bfd91 --- /dev/null +++ b/modules/scanner/assets/js/layouts/heading-structure-layout.js @@ -0,0 +1,20 @@ +import HeadingStructureHeadingTree from '@ea11y-apps/scanner/components/heading-structure/heading-tree'; +import HeadingStructureHeadingTreeEmpty from '@ea11y-apps/scanner/components/heading-structure/heading-tree-empty'; +import HeadingStructureHelpText from '@ea11y-apps/scanner/components/heading-structure/help-text'; +import { useHeadingStructureContext } from '@ea11y-apps/scanner/context/heading-structure-context'; +import { StyledContent } from '@ea11y-apps/scanner/styles/app.styles'; + +export const HeadingStructureLayout = () => { + const { isLoading, pageHeadings } = useHeadingStructureContext(); + + if (!pageHeadings.length && !isLoading) { + return ; + } + + return ( + + + + + ); +}; diff --git a/modules/scanner/assets/js/layouts/main-layout.js b/modules/scanner/assets/js/layouts/main-layout.js index 1c1b9f72..58342ebb 100644 --- a/modules/scanner/assets/js/layouts/main-layout.js +++ b/modules/scanner/assets/js/layouts/main-layout.js @@ -7,7 +7,7 @@ import { __ } from '@wordpress/i18n'; export const MainLayout = () => { return ( - + {__('All issues', 'pojo-accessibility')} diff --git a/modules/scanner/assets/js/rules/heading-hierarchy-check.js b/modules/scanner/assets/js/rules/heading-hierarchy-check.js new file mode 100644 index 00000000..21693ffa --- /dev/null +++ b/modules/scanner/assets/js/rules/heading-hierarchy-check.js @@ -0,0 +1,149 @@ +/** + * Heading Hierarchy Check Rule + * + * This rule checks for proper heading hierarchy. + * Skipping heading levels can confuse screen readers and break document outline. + * + * Compatible with ACE (accessibility-checker-engine) + */ +import { + getHeadingLevel, + getHeadingXpath, + getPageHeadings, +} from '../utils/page-headings'; + +const headingHierarchyCheck = { + id: 'heading_hierarchy_check', + context: 'dom:html', + dependencies: [], + help: { + 'en-US': { + group: 'Heading Structure', + pass: 'Heading hierarchy follows proper sequence', + fail_skipped_level: + 'Heading levels are skipped in the document. Maintain proper heading hierarchy to help screen readers understand the content structure.', + }, + }, + messages: { + 'en-US': { + group: 'Heading Structure', + pass: 'Heading hierarchy follows proper sequence', + fail_skipped_level: + 'Heading levels are skipped. Ensure headings follow proper hierarchy (h1 -> h2 -> h3).', + }, + }, + rulesets: [ + { + id: ['IBM_Accessibility'], + num: '1.3.1', + level: 'recommendation', + toolkitLevel: 'LEVEL_ONE', + }, + ], + run: (context) => { + const document = context.dom.node; + const headings = getPageHeadings(document); + + if (headings.length === 0) { + return { + ruleId: 'heading_hierarchy_check', + reasonId: 'pass', + value: ['RECOMMENDATION', 'PASS'], + path: { + dom: '/html[1]', + aria: '/document[1]', + }, + ruleTime: 0, + message: 'No headings found', + messageArgs: [], + apiArgs: [], + bounds: { + left: 0, + top: 0, + height: 0, + width: 0, + }, + snippet: '', + category: 'Accessibility', + ignored: false, + level: 'pass', + }; + } + + // Extract heading levels and check for skips + let previousLevel = 0; + let violatingElement = null; + const skippedLevels = []; + + for (const heading of headings) { + const currentLevel = getHeadingLevel(heading); + + if (previousLevel > 0 && currentLevel > previousLevel + 1) { + violatingElement = heading; + + for (let i = previousLevel + 1; i < currentLevel; i++) { + skippedLevels.push(`h${i}`); + } + + break; + } + + previousLevel = currentLevel; + } + + if (violatingElement) { + const rect = violatingElement.getBoundingClientRect(); + + return { + ruleId: 'heading_hierarchy_check', + reasonId: 'fail_skipped_level', + value: ['RECOMMENDATION', 'FAIL'], + path: { + dom: getHeadingXpath(violatingElement), + aria: '/document[1]', + }, + node: violatingElement, + ruleTime: 0, + message: `Heading levels are skipped. Ensure headings follow proper hierarchy. Missing: ${skippedLevels.join(', ')}.`, + messageArgs: [skippedLevels.join(', ')], + apiArgs: [], + bounds: { + left: Math.round(rect.left), + top: Math.round(rect.top), + height: Math.round(rect.height), + width: Math.round(rect.width), + }, + snippet: violatingElement.outerHTML, + category: 'Accessibility', + ignored: false, + level: 'recommendation', + }; + } + + return { + ruleId: 'heading_hierarchy_check', + reasonId: 'pass', + value: ['RECOMMENDATION', 'PASS'], + path: { + dom: '/html[1]', + aria: '/document[1]', + }, + ruleTime: 0, + message: 'Heading hierarchy follows proper sequence', + messageArgs: [], + apiArgs: [], + bounds: { + left: 0, + top: 0, + height: 0, + width: 0, + }, + snippet: headings[0]?.outerHTML || '', + category: 'Accessibility', + ignored: false, + level: 'pass', + }; + }, +}; + +export default headingHierarchyCheck; diff --git a/modules/scanner/assets/js/rules/index.js b/modules/scanner/assets/js/rules/index.js new file mode 100644 index 00000000..d90f454a --- /dev/null +++ b/modules/scanner/assets/js/rules/index.js @@ -0,0 +1,45 @@ +import headingHierarchyCheck from './heading-hierarchy-check'; +import missingH1Check from './missing-h1-check'; +import singleH1Check from './single-h1-check'; + +export const EA11Y_RULES = Object.freeze({ + MISSING_H1_TAG: 'missing_h1_check', + REDUNDANT_H1_TAGS: 'single_h1_check', + INCORRECT_HEADING_HIERARCHY: 'heading_hierarchy_check', +}); + +export const ea11yRuleSet = Object.freeze([ + singleH1Check, + missingH1Check, + headingHierarchyCheck, +]); + +/** + * Run all registered custom rules against the document. + * + * @param {Document} document - The document to scan + * @return {Array} Array of rule results (violations, recommendations, passes) + */ +export const runAllEa11yRules = (document) => { + const results = []; + + const context = { + dom: { + node: document, + }, + }; + + Object.values(ea11yRuleSet).forEach((rule) => { + try { + const result = rule.run(context); + + if (result) { + results.push(result); + } + } catch (error) { + console.error(`Error running custom rule ${rule.id}:`, error); + } + }); + + return results; +}; diff --git a/modules/scanner/assets/js/rules/missing-h1-check.js b/modules/scanner/assets/js/rules/missing-h1-check.js new file mode 100644 index 00000000..1147056c --- /dev/null +++ b/modules/scanner/assets/js/rules/missing-h1-check.js @@ -0,0 +1,102 @@ +/** + * Missing H1 Check Rule + * + * This rule ensures that a page contains at least one h1 tag. + * Missing h1 tags can make it difficult for screen readers to understand the page structure. + * + * Compatible with ACE (accessibility-checker-engine) + */ +import { + getPageHeadings, + getH1Headings, +} from '@ea11y-apps/scanner/utils/page-headings'; + +const missingH1Check = { + id: 'missing_h1_check', + context: 'dom:html', + dependencies: [], + help: { + 'en-US': { + group: 'Heading Structure', + pass: 'Page contains an h1 tag', + fail_no_h1: + 'No h1 tag found on the page. Each page should have a main heading (h1) to establish proper document structure and help users understand the page content.', + }, + }, + messages: { + 'en-US': { + group: 'Heading Structure', + pass: 'Page contains an h1 tag', + fail_no_h1: + 'No h1 tag found. Add a main heading (h1) to establish the page structure.', + }, + }, + rulesets: [ + { + id: ['IBM_Accessibility'], + num: '1.3.1', + level: 'violation', + toolkitLevel: 'LEVEL_ONE', + }, + ], + run: (context) => { + const document = context.dom.node; + const firstLevelHeadings = getH1Headings(document); + + if (firstLevelHeadings.length === 0) { + const headings = getPageHeadings(document); + + return { + ruleId: 'missing_h1_check', + reasonId: 'fail_no_h1', + value: ['VIOLATION', 'FAIL'], + path: { + dom: '/html[1]', + aria: '/document[1]', + }, + node: headings[0], + ruleTime: 0, + message: + 'No h1 tag found. Add a main heading (h1) to establish the page structure.', + messageArgs: [], + apiArgs: [], + bounds: { + left: 0, + top: 0, + height: 0, + width: 0, + }, + snippet: '', + category: 'Accessibility', + ignored: false, + level: 'violation', + }; + } + + return { + ruleId: 'missing_h1_check', + reasonId: 'pass', + value: ['VIOLATION', 'PASS'], + path: { + dom: '/html[1]', + aria: '/document[1]', + }, + ruleTime: 0, + message: 'Page contains an h1 tag', + messageArgs: [], + apiArgs: [], + bounds: { + left: 0, + top: 0, + height: 0, + width: 0, + }, + snippet: firstLevelHeadings[0].outerHTML, + category: 'Accessibility', + ignored: false, + level: 'pass', + }; + }, +}; + +export default missingH1Check; diff --git a/modules/scanner/assets/js/rules/single-h1-check.js b/modules/scanner/assets/js/rules/single-h1-check.js new file mode 100644 index 00000000..e5428529 --- /dev/null +++ b/modules/scanner/assets/js/rules/single-h1-check.js @@ -0,0 +1,103 @@ +/** + * Single H1 Check Rule + * + * This rule ensures that a page contains only a single h1 tag. + * Multiple h1 tags can confuse screen readers and break the document outline. + * + * Compatible with ACE (accessibility-checker-engine) + */ +import { getH1Headings } from '@ea11y-apps/scanner/utils/page-headings'; + +const singleH1Check = { + id: 'single_h1_check', + context: 'dom:html', + dependencies: [], + help: { + 'en-US': { + group: 'Heading Structure', + pass: 'Page contains exactly one h1 tag', + fail_multiple: + 'Multiple h1 tags found on the page. Each page should have only one main heading (h1) to establish proper document structure.', + pass_no_h1: + 'No h1 tag found - this may be acceptable for some page types', + }, + }, + messages: { + 'en-US': { + group: 'Heading Structure', + pass: 'Page contains exactly one h1 tag', + fail_multiple: + 'Multiple h1 tags found. Ensure the page contains only one h1 tag to maintain proper heading hierarchy.', + pass_no_h1: 'No h1 tag found', + }, + }, + rulesets: [ + { + id: ['IBM_Accessibility'], + num: '1.3.1', + level: 'violation', + toolkitLevel: 'LEVEL_ONE', + }, + ], + run: (context) => { + const document = context.dom.node; + const headings = getH1Headings(document); + + if (headings.length > 1) { + return { + ruleId: 'single_h1_check', + reasonId: 'fail_multiple', + value: ['VIOLATION', 'FAIL'], + path: { + dom: '/html[1]', + aria: '/document[1]', + }, + node: headings[1], + ruleTime: 0, + message: + 'Multiple h1 tags found. Ensure the page contains only one h1 tag to maintain proper heading hierarchy.', + messageArgs: [headings.length.toString()], + apiArgs: [], + bounds: { + left: 0, + top: 0, + height: 0, + width: 0, + }, + snippet: `Found ${headings.length} h1 tags`, + category: 'Accessibility', + ignored: false, + level: 'violation', + }; + } + + return { + ruleId: 'single_h1_check', + reasonId: 'pass', + value: ['VIOLATION', 'PASS'], + path: { + dom: '/html[1]', + aria: '/document[1]', + }, + ruleTime: 0, + message: + headings.length === 1 + ? 'Page contains exactly one h1 tag' + : 'Page contains no duplicate h1 tags', + messageArgs: [], + apiArgs: [], + bounds: { + left: 0, + top: 0, + height: 0, + width: 0, + }, + snippet: headings.length > 0 ? headings[0].outerHTML : '', + category: 'Accessibility', + ignored: false, + level: 'pass', + }; + }, +}; + +export default singleH1Check; diff --git a/modules/scanner/assets/js/styles/app.styles.js b/modules/scanner/assets/js/styles/app.styles.js index ee09445e..42d8e8d1 100644 --- a/modules/scanner/assets/js/styles/app.styles.js +++ b/modules/scanner/assets/js/styles/app.styles.js @@ -21,9 +21,11 @@ export const AppContainer = styled(Paper)` ${ColorPickerStyles} `; -export const HeaderCard = styled(Card)` - border-radius: 8px; - margin-bottom: ${({ theme }) => theme.spacing(2)}; +export const StyledStatsBlock = styled(Card)` + margin: ${({ theme }) => `${theme.spacing(3)} ${theme.spacing(2)}`}; + padding: ${({ theme }) => theme.spacing(2)}; + + border-radius: ${({ theme }) => theme.shape.borderRadius}px; box-shadow: 0 3px 14px 2px rgba(0, 0, 0, 0.12); `; @@ -33,12 +35,28 @@ export const TitleBox = styled(Box)` align-items: center; `; -export const HeaderContent = styled(CardContent)` - &:last-child { - padding-bottom: ${({ theme }) => theme.spacing(2)}; +export const StyledTitle = styled(Typography)` + font-size: 16px; + font-weight: 500; + line-height: 130%; + letter-spacing: 0.15px; + margin: 0; + + .MuiChip-root { + margin-inline-start: ${({ theme }) => theme.spacing(1)}; + + font-weight: 400; } `; +export const StyledHeaderTitleWrapper = styled(Box)` + padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(1.5)}`}; +`; + +export const StyledHeaderContent = styled(Box)` + padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(1.5)}`}; +`; + export const StyledContent = styled(CardContent)` padding: 0 ${({ theme }) => theme.spacing(2)}; `; diff --git a/modules/scanner/assets/js/styles/heading-structure.styles.js b/modules/scanner/assets/js/styles/heading-structure.styles.js new file mode 100644 index 00000000..afba5f1f --- /dev/null +++ b/modules/scanner/assets/js/styles/heading-structure.styles.js @@ -0,0 +1,212 @@ +import Alert from '@elementor/ui/Alert'; +import Box from '@elementor/ui/Box'; +import Button from '@elementor/ui/Button'; +import FormControlLabel from '@elementor/ui/FormControlLabel'; +import Select from '@elementor/ui/Select'; +import Stack from '@elementor/ui/Stack'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; +import { STATUS_CONFIG } from '@ea11y-apps/scanner/components/heading-structure/constants'; + +export const StyledDescription = styled(Typography)` + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: 14px; + line-height: 143%; + letter-spacing: 0.15px; + + &:first-of-type { + margin-top: ${({ theme }) => theme.spacing(2)}; + margin-bottom: ${({ theme }) => theme.spacing(1)}; + } + + &:last-of-type { + margin: 0; + + font-size: 13px; + } + + span { + font-weight: 600; + } +`; + +export const StyledTitleRowContainer = styled(Stack)` + display: flex; + align-items: center; + + padding: ${({ theme }) => theme.spacing(1.5)}; + + box-shadow: 0 1px 5px 0 ${({ theme }) => theme.palette.divider}; + background-color: ${({ theme }) => theme.palette.background.default}; +`; + +export const StyledTitleRowItem = styled(Box)` + display: flex; + align-items: center; +`; + +export const StyledTitleRowItemTypography = styled(Typography)` + margin-inline-start: ${({ theme }) => theme.spacing(0.5)}; + + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: 14px; + font-weight: 400; + line-height: 18px; + letter-spacing: 0.15px; + + b { + font-weight: 600; + } +`; + +export const StyledTreeList = styled('ul')` + padding: 0; + + li { + list-style-type: none; + } +`; + +export const StyledTreeListItem = styled('li', { + shouldForwardProp: (prop) => prop !== 'isExpanded', +})` + box-sizing: border-box; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + + margin-bottom: ${({ theme }) => theme.spacing(1.25)}; + box-shadow: ${({ isExpanded, theme }) => + isExpanded + ? `0 0 0 2px ${theme.palette.action.active}` + : `0 0 0 1px ${theme.palette.divider}`}; + + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + user-select: none; + transition: 300ms ease-in-out; + + &:hover { + box-shadow: ${({ isExpanded, theme }) => + isExpanded + ? `0 0 0 2px ${theme.palette.action.active}` + : `0 0 0 1px ${theme.palette.text.primary}`}; + + transition: 300ms ease-in-out; + } + + & * { + box-sizing: border-box; + } +`; + +export const StyledListItemTopWrapper = styled(Button, { + shouldForwardProp: (prop) => !['isExpanded', 'level'].includes(prop), +})` + width: 100%; + + display: flex; + justify-content: flex-start; + align-items: center; + + padding: ${({ theme }) => theme.spacing(1)}; + padding-inline-start: calc( + (12px * ${({ level }) => level - 1}) + ${({ theme }) => theme.spacing(1)} + ); + transition: 300ms ease-in-out; + cursor: pointer; + + &:hover { + background-color: ${({ isExpanded, theme }) => + isExpanded ? 'initial' : theme.palette.action.hover}; + transition: 300ms ease-in-out; + } + + .MuiSvgIcon-root { + margin-inline-start: auto; + } +`; + +export const StyledListItemDetails = styled(Box)` + width: 100%; + max-height: 500px; + + padding: ${({ theme }) => theme.spacing(1)}; + padding-top: 0; +`; + +export const StyledListItemLevelBox = styled(Box, { + shouldForwardProp: (prop) => prop !== 'status', +})` + width: 32px; + height: 32px; + + padding: ${({ theme }) => `${theme.spacing(0.5)} ${theme.spacing(0.75)}`}; + margin-inline-end: ${({ theme }) => theme.spacing(1)}; + + border: 1px solid ${({ status }) => STATUS_CONFIG[status].borderColor}; + background: #f3f3f4; + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + + span { + color: ${({ status }) => STATUS_CONFIG[status].textColor}; + } +`; + +export const StyledListItemContent = styled(Typography, { + shouldForwardProp: (prop) => prop !== 'level', +})` + max-width: calc(268px - (12px * ${({ level }) => level - 1})); + + color: ${({ theme }) => theme.palette.text.primary}; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const StyledListItemAlert = styled(Alert)` + margin-top: ${({ theme }) => theme.spacing(1.5)}; + padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(1.5)}`}; +`; + +export const StyledListItemBottomWrapper = styled(Box)` + width: 100%; + + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + +export const StyledListItemSelect = styled(Select)` + .MuiSelect-select { + .MuiTypography-root b { + font-weight: 400; + } + } +`; + +export const StyledListItemActionsWrapper = styled(Box)` + width: 100%; + + display: flex; + align-items: center; + + margin-top: ${({ theme }) => theme.spacing(2)}; + + .MuiButton-text { + margin-inline-start: auto; + margin-inline-end: ${({ theme }) => theme.spacing(1)}; + } +`; + +export const StyledListItemDismissLabel = styled(FormControlLabel)` + margin-left: -7px; + + .MuiTypography-root { + font-size: 14px; + } +`; diff --git a/modules/scanner/assets/js/types/heading.js b/modules/scanner/assets/js/types/heading.js new file mode 100644 index 00000000..bfa20824 --- /dev/null +++ b/modules/scanner/assets/js/types/heading.js @@ -0,0 +1,35 @@ +import { __ } from '@wordpress/i18n'; + +/** + * Heading structure definition. + * + * @typedef {Object} Ea11yHeading + * @property {1 | 2 | 3 | 4 | 5 | 6 | null} level - Heading level. + * @property {string} content - The innerText of the heading. + * @property {HTMLElement} node - Heading node. Could be not an instance of HTMLHeadingElement. + * @property {Ea11yHeading[]} children - Child nodes of the heading. + * @property {'success' | 'error' | 'warning'} status - Validation status. + * @property {string | null} violationCode - Validation violation code. + */ + +/** + * Heading validation stats definition. + * + * @typedef {Object} Ea11yHeadingStats + * @property {number} total - Total number of headings. + * @property {number} success - Headings passed validation. + * @property {number} error - Number of headings with violations. + * @property {number} warning - Number of headings with warnings. + */ + +export const HEADING_STATUS = Object.freeze({ + SUCCESS: 'success', + ERROR: 'error', + WARNING: 'warning', +}); + +export const HEADING_STATUS_DESCRIPTION = Object.freeze({ + [HEADING_STATUS.SUCCESS]: __('Validation passed', 'pojo-accessibility'), + [HEADING_STATUS.ERROR]: __('Validation error', 'pojo-accessibility'), + [HEADING_STATUS.WARNING]: __('Has a warning', 'pojo-accessibility'), +}); diff --git a/modules/scanner/assets/js/types/scanner-item.js b/modules/scanner/assets/js/types/scanner-item.js index 3f178aa8..323893b8 100644 --- a/modules/scanner/assets/js/types/scanner-item.js +++ b/modules/scanner/assets/js/types/scanner-item.js @@ -2,15 +2,25 @@ import PropTypes from 'prop-types'; export const scannerItem = PropTypes.shape({ ruleId: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.number).isRequired, + value: PropTypes.arrayOf( + PropTypes.oneOf([ + 'PASS', + 'FAIL', + 'POTENTIAL', + 'MANUAL', + 'VIOLATION', + 'RECOMMENDATION', + 'INFORMATION', + ]), + ).isRequired, path: PropTypes.shape({ dom: PropTypes.string.isRequired, aria: PropTypes.string.isRequired, - selector: PropTypes.string.isRequired, + selector: PropTypes.string, }).isRequired, messageArgs: PropTypes.arrayOf(PropTypes.string), reasonCategory: PropTypes.string.isRequired, category: PropTypes.string.isRequired, level: PropTypes.string.isRequired, - node: PropTypes.node, + node: PropTypes.object, }); diff --git a/modules/scanner/assets/js/utils/get-element-context.js b/modules/scanner/assets/js/utils/get-element-context.js new file mode 100644 index 00000000..7d29ce06 --- /dev/null +++ b/modules/scanner/assets/js/utils/get-element-context.js @@ -0,0 +1,52 @@ +export const getElementContext = (currentEl) => { + const headingSelector = 'h1, h2, h3, h4, h5, h6'; + const context = { + previousSiblings: [], + parentElements: [], + sectionHeadings: [], + }; + + // Find headings among previous sibling elements + let sibling = currentEl.previousElementSibling; + while (sibling) { + if (sibling.matches(headingSelector)) { + context.previousSiblings.push({ + outerHTML: sibling.outerHTML, + outerText: sibling.outerText, + }); + } + sibling = sibling.previousElementSibling; + } + + // Traverse up the DOM tree to find headings in ancestor elements + let ancestor = currentEl; + while (ancestor) { + // Check if this ancestor element itself is a heading + if (ancestor.matches(headingSelector)) { + context.parentElements.push({ + outerHTML: ancestor.outerHTML, + outerText: ancestor.outerText, + }); + } + + // Find section headers that are direct children of this ancestor + const scopedHeadingSelector = headingSelector + .split(',') + .map((sel) => `:scope > ${sel.trim()}`) + .join(', '); + const sectionHeaders = ancestor.querySelectorAll(scopedHeadingSelector); + + sectionHeaders?.forEach((sectionHeader) => { + if (sectionHeader) { + context.sectionHeadings.push({ + outerHTML: sectionHeader.outerHTML, + outerText: sectionHeader.outerText, + }); + } + }); + + ancestor = ancestor.parentElement; + } + + return context; +}; diff --git a/modules/scanner/assets/js/utils/page-headings.js b/modules/scanner/assets/js/utils/page-headings.js new file mode 100644 index 00000000..4811af2b --- /dev/null +++ b/modules/scanner/assets/js/utils/page-headings.js @@ -0,0 +1,158 @@ +import getXPath from 'get-xpath'; +import { HEADING_STATUS } from '../types/heading'; + +/** + * Convert DOM node to a simplified structure. + * + * @param {HTMLHeadingElement} node DOM node. + * @return {import('../types/heading').Ea11yHeading} Heading object. + */ +const createHeadingObject = (node) => ({ + level: getHeadingLevel(node), + content: node.textContent.trim(), + node, + children: [], + status: HEADING_STATUS.SUCCESS, + violationCode: null, +}); + +/** + * Retrieves heading level based on the Hx number or aria-level attribute. + * + * @param {HTMLElement} element DOM node. + * @return {number | null} Heading level if it can be parsed, null otherwise. + */ +export const getHeadingLevel = (element) => { + const ariaLevel = element.getAttribute('aria-level'); + + if (ariaLevel) { + return parseInt(ariaLevel, 10); + } + + if ( + 'H' === element.tagName[0] && + 2 === element.tagName.length && + element.tagName[1] <= 6 + ) { + return parseInt(element.tagName[1], 10); + } + + return null; +}; + +export const getPageHeadings = (parent = document) => { + return Array.from( + parent.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]'), + ).filter((heading) => { + return 'presentation' !== heading.getAttribute('role'); + }); +}; + +export const getH1Headings = (parent = document) => + Array.from( + parent.querySelectorAll('h1, [role="heading"][aria-level="1"]'), + ).filter((heading) => { + const ariaLevel = parseInt(heading.getAttribute('aria-level'), 10); + + return ( + !['none', 'presentation'].includes(heading.getAttribute('role')) && + !(heading.tagName.toLowerCase() === 'h1' && ariaLevel > 1) + ); + }); + +export const areNoHeadingsDefined = () => { + return getPageHeadings().length === 0; +}; + +/** + * Builds an n-nary tree of page headings. + * + * @return {import('../types/heading').Ea11yHeading[]} Headings tree. + */ +export const getPageHeadingsTree = () => { + const headings = getPageHeadings(); + + const root = []; + const stack = []; + + headings.forEach((headingHTMLElement) => { + const heading = createHeadingObject(headingHTMLElement); + const level = heading.level; + + // Pop stack until we find the correct parent level + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + if (stack.length === 0) { + root.push(heading); + } else { + stack[stack.length - 1].node.children.push(heading); + } + + // Push the current heading onto the stack + stack.push({ node: heading, level }); + }); + + return root; +}; + +/** + * Converts an n-nary tree to a flat tree. + * + * @param {import('../types/heading').Ea11yHeading[]} headings N-nary tree [a->b->c, d]. + * @return {import('../types/heading').Ea11yHeading[]} Headings flat tree [a, b, c, d]. + */ +export const toFlatTree = (headings) => { + const output = []; + + const walk = (h) => { + h.forEach((heading) => { + output.push(heading); + + if (heading.children && heading.children.length > 0) { + walk(heading.children); + } + }); + }; + + walk(headings); + + return output; +}; + +/** + * Returns the XPath of the element. + * + * @param {HTMLElement} node DOM node. + * @return {string} Heading XPath. + */ +export const getHeadingXpath = (node) => { + return getXPath(node, { ignoreId: true }); +}; + +const nodeKeyMap = new WeakMap(); +let nodeIdCounter = 0; + +/** + * Returns a stable, serializable key for a DOM Node across renders. + * Falls back to a deterministic prefix when input is invalid. + * + * @param {Object} node + * @param {string} [prefix='heading-tree'] + * @return {string} Unique key + */ +export const keyForNode = (node, prefix = 'heading-tree') => { + if (!node || 'object' !== typeof node) { + return `${prefix}-invalid`; + } + + let key = nodeKeyMap.get(node); + + if (!key) { + key = `${prefix}-${++nodeIdCounter}`; + nodeKeyMap.set(node, key); + } + + return key; +}; diff --git a/modules/scanner/assets/js/utils/validate-headings.js b/modules/scanner/assets/js/utils/validate-headings.js new file mode 100644 index 00000000..253aed22 --- /dev/null +++ b/modules/scanner/assets/js/utils/validate-headings.js @@ -0,0 +1,102 @@ +import { + toFlatTree, + getHeadingXpath, +} from '@ea11y-apps/scanner/utils/page-headings'; +import { EA11Y_RULES } from '../rules'; +import { HEADING_STATUS } from '../types/heading'; + +/** + * Validates the heading tree and mutates it to show notices in the UI. + * + * @param {import('../types/heading').Ea11yHeading[]} headingTree + * @param {string[]} dismissedHeadingIssues + * @return {import('../types/heading').Ea11yHeading[]} Validated heading tree. + */ +export const validateHeadings = (headingTree, dismissedHeadingIssues) => { + if (!headingTree.length) { + return headingTree; + } + + const clone = [...headingTree]; + const flatHeadings = toFlatTree(clone).filter( + (heading) => + !['none', 'presentation'].includes(heading.node.getAttribute('role')), + ); + const h1Titles = flatHeadings.filter((heading) => 1 === heading.level); + + if (h1Titles.length > 1) { + h1Titles.forEach((heading) => { + heading.status = HEADING_STATUS.ERROR; + heading.violationCode = EA11Y_RULES.REDUNDANT_H1_TAGS; + }); + } + + if (!h1Titles.length) { + clone[0].status = HEADING_STATUS.ERROR; + clone[0].violationCode = EA11Y_RULES.MISSING_H1_TAG; + } + + validateHierarchy(clone, dismissedHeadingIssues); + + return clone; +}; + +/** + * @param {import('../types/heading').Ea11yHeading[]} headings + * @param {string[]} dismissedHeadingIssues + * @param {number} parentLevel + */ +const validateHierarchy = ( + headings, + dismissedHeadingIssues, + parentLevel = 0, +) => { + let previousLevel = parentLevel; + + headings.forEach((heading) => { + const currentLevel = heading.level; + + if (previousLevel > 0 && currentLevel > previousLevel + 1) { + const xpath = getHeadingXpath(heading.node); + + if ( + // Important to not overwrite HEADING_STATUS.ERROR + heading.status === HEADING_STATUS.SUCCESS && + !dismissedHeadingIssues.includes(xpath) + ) { + heading.status = HEADING_STATUS.WARNING; + heading.violationCode = EA11Y_RULES.INCORRECT_HEADING_HIERARCHY; + } + } + + previousLevel = currentLevel; + + if (heading.children && heading.children.length > 0) { + validateHierarchy(heading.children, dismissedHeadingIssues, currentLevel); + } + }); +}; + +/** + * Returns heading validation stats. + * + * @param {import('../types/heading').Ea11yHeading[]} headings Validated heading tree. + * @return {import('../types/heading').Ea11yHeadingStats} Validation stats. + */ +export const calculateStats = (headings) => { + const flatHeadings = toFlatTree(headings); + const output = { + total: flatHeadings.length, + [HEADING_STATUS.SUCCESS]: 0, + [HEADING_STATUS.WARNING]: 0, + [HEADING_STATUS.ERROR]: 0, + }; + + flatHeadings.forEach((heading) => { + const status = heading.status; + + output[status]++; + }); + + return output; +}; diff --git a/modules/scanner/module.php b/modules/scanner/module.php index f3059e6b..02cc49c1 100644 --- a/modules/scanner/module.php +++ b/modules/scanner/module.php @@ -83,6 +83,12 @@ public function enqueue_assets() : void { 'value' => $url, ]); + $dismissed_heading_issues = get_post_meta( get_the_ID(), 'ea11y-scanner-heading-issues-dismissed', true ); + + if ( ! $dismissed_heading_issues ) { + $dismissed_heading_issues = []; + } + wp_localize_script( 'scanner', 'ea11yScannerData', @@ -107,6 +113,7 @@ public function enqueue_assets() : void { 'isConnected' => Connect::is_connected(), 'isRTL' => is_rtl(), 'isDevelopment' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG, + 'dismissedHeadingIssues' => $dismissed_heading_issues, ] ); } diff --git a/modules/scanner/rest/resolve-issue.php b/modules/scanner/rest/resolve-issue.php index c654c9d8..12c92dee 100644 --- a/modules/scanner/rest/resolve-issue.php +++ b/modules/scanner/rest/resolve-issue.php @@ -10,58 +10,58 @@ use WP_REST_Response; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly + exit; // Exit if accessed directly } class Resolve_Issue extends Route_Base { - public string $path = 'resolve-issue'; + public string $path = 'resolve-issue'; - public function get_methods(): array { - return [ 'POST' ]; - } + public function get_methods(): array { + return [ 'POST' ]; + } - public function get_name(): string { - return 'resolve-issue'; - } + public function get_name(): string { + return 'resolve-issue'; + } - /** - * - * @return WP_Error|WP_REST_Response - * - */ - public function POST( $request ) { - try { - $error = $this->verify_capability(); + /** + * + * @return WP_Error|WP_REST_Response + * + */ + public function POST( $request ) { + try { + $error = $this->verify_capability(); - if ( $error ) { - return $error; - } + if ( $error ) { + return $error; + } - $scan_id = $request->get_param( 'scanId' ); - $scan = Scan_Entry::get_by_id( $scan_id ); + $scan_id = $request->get_param( 'scanId' ); + $scan = Scan_Entry::get_by_id( $scan_id ); - if ( empty( $scan ) ) { - return $this->respond_error_json( [ - 'message' => 'Scan not found', - 'code' => 'not_found', - ] ); - } + if ( empty( $scan ) ) { + return $this->respond_error_json( [ + 'message' => 'Scan not found', + 'code' => 'not_found', + ] ); + } - $summary = $scan->summary; + $summary = $scan->summary; - if ($summary['counts']['issuesResolved'] + 1 <= $summary['counts']['violation']) { - $summary['counts']['issuesResolved']++; - Scan_Entry::update_scan_summary( Scans_Table::ID, $scan_id, json_encode( $summary ) ); - } + if ( $summary['counts']['issuesResolved'] + 1 <= $summary['counts']['violation'] ) { + $summary['counts']['issuesResolved']++; + Scan_Entry::update_scan_summary( Scans_Table::ID, $scan_id, json_encode( $summary ) ); + } - return $this->respond_success_json( [ - 'message' => 'Resolved', - ] ); - } catch ( Throwable $t ) { - return $this->respond_error_json( [ - 'message' => $t->getMessage(), - 'code' => 'internal_server_error', - ] ); - } - } + return $this->respond_success_json( [ + 'message' => 'Resolved', + ] ); + } catch ( Throwable $t ) { + return $this->respond_error_json( [ + 'message' => $t->getMessage(), + 'code' => 'internal_server_error', + ] ); + } + } } diff --git a/modules/scanner/rest/resolve-with-ai.php b/modules/scanner/rest/resolve-with-ai.php index 6d986208..55f71a12 100644 --- a/modules/scanner/rest/resolve-with-ai.php +++ b/modules/scanner/rest/resolve-with-ai.php @@ -38,6 +38,7 @@ public function POST( $request ) { $snippet = $request->get_param( 'snippet' ); $violation = $request->get_param( 'violation' ); + $context = $request->get_param( 'context' ); $result = Global_Utils::get_api_client()->make_request( 'POST', @@ -45,6 +46,7 @@ public function POST( $request ) { [ 'snippet' => $snippet, 'violation' => $violation, + 'htmlContext' => $context, ], [], true, diff --git a/modules/settings/assets/js/components/sidebar-menu/menu-item.js b/modules/settings/assets/js/components/sidebar-menu/menu-item.js index 44a7761d..204d0a26 100644 --- a/modules/settings/assets/js/components/sidebar-menu/menu-item.js +++ b/modules/settings/assets/js/components/sidebar-menu/menu-item.js @@ -37,7 +37,7 @@ const MenuItem = ({ keyName, item }) => { window.location.hash = parentKey; mixpanelService.sendEvent(mixpanelEvents.menuButtonClicked, { - buttonName: itemName, + buttonName: childKey || parentKey, }); }; @@ -47,7 +47,7 @@ const MenuItem = ({ keyName, item }) => { [itemKey]: !prev[itemKey], // Toggle the expanded state for the clicked item })); mixpanelService.sendEvent(mixpanelEvents.menuButtonClicked, { - buttonName: itemName, + buttonName: itemKey, }); }; diff --git a/modules/settings/assets/js/components/sitemap-settings/index.js b/modules/settings/assets/js/components/sitemap-settings/index.js index ac3ff2aa..a8a7eb64 100644 --- a/modules/settings/assets/js/components/sitemap-settings/index.js +++ b/modules/settings/assets/js/components/sitemap-settings/index.js @@ -66,7 +66,7 @@ const SitemapSettings = ({ sitemap }) => { - {__('Sitemap URL')} + {__('Sitemap URL', 'pojo-accessibility')} { settings.homeUrl = settings.homeUrl; } + if ('whatsNewDataHash' in settings) { + settings.whatsNewDataHash = Boolean(settings.whatsNewDataHash); + } + setPluginSettings(settings); setLoaded(true); }) diff --git a/modules/settings/assets/js/layouts/top-bar-menu.js b/modules/settings/assets/js/layouts/top-bar-menu.js index 6fad2506..7bc68e0a 100644 --- a/modules/settings/assets/js/layouts/top-bar-menu.js +++ b/modules/settings/assets/js/layouts/top-bar-menu.js @@ -1,36 +1,46 @@ -import { HelpIcon, UserIcon } from '@elementor/icons'; +import { HelpIcon, UserIcon, PointFilledIcon } from '@elementor/icons'; import Box from '@elementor/ui/Box'; import Button from '@elementor/ui/Button'; import Divider from '@elementor/ui/Divider'; import IconButton from '@elementor/ui/IconButton'; import Tooltip from '@elementor/ui/Tooltip'; +import { styled } from '@elementor/ui/styles'; import { bindMenu, bindTrigger, usePopupState, } from '@elementor/ui/usePopupState'; import { PopupMenu } from '@ea11y/components'; -import WhatsNewDrawer from '@ea11y/components/whats-new/drawer'; import { useWhatsNew } from '@ea11y/hooks/use-whats-new'; import { BulbIcon } from '@ea11y/icons'; import SpeakerphoneIcon from '@ea11y/icons/speakerphone-icon'; import { GOLINKS } from '@ea11y-apps/global/constants'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import WhatsNewDrawer from '../components/whats-new/drawer'; +import { usePluginSettingsContext } from '../contexts/plugin-settings'; import { openLink } from '../utils/index'; const TopBarMenu = () => { const { isSidebarOpen, open, close } = useWhatsNew(); + const { whatsNewDataHash } = usePluginSettingsContext(); const accountMenuState = usePopupState({ variant: 'popover', popupId: 'myAccountMenu', }); + const [showWhatsNewDataHash, setShowWhatsNewDataHash] = useState(false); + + useEffect(() => { + setShowWhatsNewDataHash(whatsNewDataHash); + }, [whatsNewDataHash]); const handleWhatsNewButtonClick = () => { mixpanelService.sendEvent(mixpanelEvents.menuButtonClicked, { buttonName: 'Whats new?', }); open(); + setShowWhatsNewDataHash(false); }; const handleHelpButtonClick = () => { @@ -75,6 +85,7 @@ const TopBarMenu = () => { onClick={handleWhatsNewButtonClick} > {__("What's new", 'pojo-accessibility')} + {showWhatsNewDataHash && }