fix_enabled( 'skip_links' ) ) { // Classic themes: wp_body_open fires right after opens. // Priority -1 beats wp_admin_bar_render which runs at priority 0. add_action( 'wp_body_open', array( $this, 'render_skip_link' ), -1 ); // Block/FSE themes (e.g. Twenty Twenty-Five) never call wp_body_open. // wp_footer is a universal fallback; the JS will move the element to // the top of so it is still the first focusable item. add_action( 'wp_footer', array( $this, 'render_skip_link_fallback' ), 0 ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_skip_link_assets' ) ); } if ( $this->fix_enabled( 'focus_outlines' ) ) { // Priority 100 ensures 'global-styles' has been registered by core // (which hooks at the default priority 10) before we check it as // a dependency below. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_focus_outline_assets' ), 100 ); } if ( $this->fix_enabled( 'html_lang' ) ) { add_filter( 'language_attributes', array( $this, 'ensure_html_lang' ), 99 ); } // New v2 fixers. if ( $this->fix_enabled( 'search_form_label' ) ) { add_filter( 'get_search_form', array( $this, 'fix_search_form_label' ), 99 ); } if ( $this->fix_enabled( 'empty_search_results' ) ) { add_action( 'pre_get_posts', array( $this, 'fix_empty_search_results' ) ); } // DOM-level v2 fixers run from a single JS file, gated per-fix by // flags localised into the script. Enqueued only when at least one // is on so we do not ship dead bytes. $dom_fixes_on = $this->fix_enabled( 'remove_positive_tabindex' ) || $this->fix_enabled( 'remove_redundant_title' ) || $this->fix_enabled( 'viewport_scalable' ) || $this->fix_enabled( 'label_new_window' ) || $this->fix_enabled( 'label_document_links' ) || $this->fix_enabled( 'force_link_underline' ) || $this->fix_enabled( 'label_comment_form' ) || $this->fix_enabled( 'autoplay_video_controls' ); if ( $dom_fixes_on ) { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_runtime_fixers' ), 20 ); } // Footer accessibility statement link - independent of fix toggles. if ( (bool) get_option( 'acceguru_statement_footer_link', false ) ) { add_action( 'wp_footer', array( $this, 'render_statement_footer_link' ), 100 ); } } /** * Outputs a small accessibility statement link in the site footer. * Linked to the published statement page configured in the Statement settings. */ public function render_statement_footer_link(): void { $page_id = (int) get_option( 'acceguru_statement_page_id', 0 ); if ( $page_id <= 0 || 'publish' !== get_post_status( $page_id ) ) { return; } $url = (string) get_permalink( $page_id ); $text = get_option( 'acceguru_statement_footer_text', '' ); $text = is_scalar( $text ) ? (string) $text : ''; if ( '' === $text ) { $text = __( 'Accessibility Statement', 'acceguru' ); } printf( '', esc_url( $url ), esc_html( $text ) ); self::fix_applied( 'statement_footer_link', 1, array( 'runtime' => 'php', 'page_id' => $page_id ) ); } /** * Returns true when a given fix is enabled. Reads the wp_options toggle * and runs it through the per-fix filter. * * @param string $name Fix name without the 'acceguru_fix_' prefix. * @return bool */ public static function fix_enabled( string $name ): bool { $option = (bool) ( 1 === absint( get_option( 'acceguru_fix_' . $name, 0 ) ) ); return (bool) apply_filters( 'acceguru_fix_enabled_' . $name, $option ); } /** * Emits the standard "fix applied" event. * * Fired from the most demonstrative fixers (skip link, html lang, * statement footer link, runtime fixers via JS). * * @param string $name Fixer slug, e.g. 'skip_links', 'html_lang', 'form_labels'. * @param int $count How many DOM nodes / cases this fixer touched (1 if not countable). * @param array $context Optional extras: post_id, runtime ('php' | 'js'), selector, etc. */ public static function fix_applied( string $name, int $count = 1, array $context = array() ): void { /** * Fires every time a fixer applies its change. * * @param string $name Fixer slug. * @param int $count Number of nodes / cases touched. * @param array $context Extras (post_id, runtime, selector...). */ do_action( 'acceguru_fix_applied', $name, $count, $context ); } // ------------------------------------------------------------------------- // Skip links - WCAG 2.4.1 // ------------------------------------------------------------------------- /** * Outputs the "Skip to main content" link via wp_body_open (classic themes). */ public function render_skip_link(): void { $this->skip_link_rendered = true; $this->output_skip_link_html(); } /** * Fallback for block/FSE themes that do not call wp_body_open. * Outputs the skip link inside wp_footer; the JS moves it to the top of * so it remains the first focusable element on the page. */ public function render_skip_link_fallback(): void { if ( $this->skip_link_rendered ) { return; } $this->output_skip_link_html(); } /** * Prints the skip link anchor. Shared by both render methods. */ private function output_skip_link_html(): void { printf( '%s', esc_html__( 'Skip to main content', 'acceguru' ) ); self::fix_applied( 'skip_links', 1, array( 'runtime' => 'php' ) ); } /** * Enqueues the shared fixer stylesheet and the skip-link resolver script. * The stylesheet handle is shared with the focus-outlines fix and is * idempotent - wp_enqueue_style is a no-op when the handle already exists. */ public function enqueue_skip_link_assets(): void { wp_enqueue_style( 'acceguru-fixer', ACCEGURU_URL . 'assets/css/acceguru-fixer.css', array(), ACCEGURU_VERSION ); wp_enqueue_script( 'acceguru-skip-link', ACCEGURU_URL . 'assets/js/acceguru-skip-link.js', array(), ACCEGURU_VERSION, true ); // Custom skip-link targets from settings: comma- (or whitespace-) // separated CSS selectors. Tried first, before the default list. $raw = get_option( 'acceguru_skip_link_targets', '' ); $raw = is_scalar( $raw ) ? (string) $raw : ''; $tokens = preg_split( '/[\s,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY ); $targets = is_array( $tokens ) ? array_values( array_filter( array_map( 'trim', $tokens ) ) ) : array(); wp_localize_script( 'acceguru-skip-link', 'acceguruSkipLink', array( 'targets' => $targets ) ); } // ------------------------------------------------------------------------- // Focus outlines - WCAG 2.4.7 / 2.4.11 // ------------------------------------------------------------------------- /** * Enqueues the focus outline stylesheet on the front end. * * Declares 'global-styles' as a dependency when it exists so that our * stylesheet is output AFTER the block-theme global stylesheet in . * This ensures our !important colour rules win the cascade when both * declarations carry !important and specificity is equal - source order * (last declaration wins) is the tie-breaker. */ public function enqueue_focus_outline_assets(): void { $deps = array(); // Block themes (WP 5.8+) register 'global-styles'; declaring it as a // dependency forces our to appear after theirs in . if ( wp_style_is( 'global-styles', 'registered' ) ) { $deps[] = 'global-styles'; } wp_enqueue_style( 'acceguru-fixer', ACCEGURU_URL . 'assets/css/acceguru-fixer.css', $deps, ACCEGURU_VERSION ); } // ------------------------------------------------------------------------- // HTML lang attribute - WCAG 3.1.1 // ------------------------------------------------------------------------- /** * Filter callback for language_attributes. Ensures a non-empty lang="" * attribute is present on . WordPress core already outputs this in * almost every theme, but some templates and child themes drop it; this * acts as a safety net. * * @param string $attributes The raw attribute string (e.g. lang="en-US"). * @return string */ public function ensure_html_lang( string $attributes ): string { $changed = false; // --- lang --- // A real lang is present when lang= exists and is not an empty lang="". $has_real_lang = ( false !== stripos( $attributes, 'lang=' ) ) && ! preg_match( '/lang=(["\'])\s*\1/', $attributes ); if ( ! $has_real_lang ) { $locale = str_replace( '_', '-', (string) get_bloginfo( 'language' ) ); if ( '' === $locale ) { $locale = str_replace( '_', '-', (string) get_locale() ); } if ( '' !== $locale ) { // Strip any empty lang="" we found, then prepend a fresh one. $attributes = (string) preg_replace( '/\s*lang=(["\'])\s*\1/', '', $attributes ); $prefix = sprintf( 'lang="%s"', esc_attr( $locale ) ); $attributes = '' === trim( $attributes ) ? $prefix : $prefix . ' ' . trim( $attributes ); $changed = true; } } // --- dir --- // Text direction (rtl/ltr) so RTL languages (Arabic, Hebrew) render the // right way round. Only added when the theme has not set one already. if ( false === stripos( $attributes, 'dir=' ) ) { $attributes = trim( $attributes . ' ' . sprintf( 'dir="%s"', esc_attr( is_rtl() ? 'rtl' : 'ltr' ) ) ); $changed = true; } if ( $changed ) { self::fix_applied( 'html_lang', 1, array( 'runtime' => 'php' ) ); } return $attributes; } // ------------------------------------------------------------------------- // V2 fixers // ------------------------------------------------------------------------- /** * Gives the WordPress search field an accessible name by adding aria-label * directly to the , so the field is named even when the theme hides * or omits a visible label. Leaves the field alone only when it already has * a NON-EMPTY name: a non-blank aria-label or title on the field, an * aria-labelledby that points at an existing element with real text, a *