DUPLICATE: Accessibility Issue: Toggle Mode is inaccessible for keyboard navigation

I just tested the new Toggle Mode Element and it has some accessibility issues.
For starters, you cannot toggle the Nightmode via keyboard, despite the Element being a <button>. This is because the actual event listeners are put on the SVGs within the button.

I would suggest putting the event-listener on the button and adjusting the JS. This would be the most straightforward solution.

Otherwise you could put tabindex="0" on the SVGs but you also needed to apply the aria-label & manage keyboard inputs manually, which wouldn’t be ideal.

Best Regards
Suat

Also there are other problems with the Nightmode in my opinion. The fact that the server doesn’t know the state of it, results in flickering of the default mode whenever the user refreshes the page. This wouldn’t happen if it the information was saved either as a cookie or for logged in users inside the user_meta.

I already implemented a working nightmode as a Bricks Element that uses Cookies and User Meta and doesn’t suffer from flashing the wrong theme in the first few frames. Feel free to take inspiration from it.

Bricks Element

<?php

if (!defined('ABSPATH')) exit;

use Userfreunde\Ajax\Nightmode;
use Userfreunde\Helpers;

class UF_Nightmode extends Userfreunde\Bricks\Element
{
    public $category     = self::CATEGORY_SPECIAL;
    public $name         = 'uf-nightmode';
    public $icon         = 'ion-md-moon';
    public $tag          = 'button';

    // MARK: Get Label
    public function get_label()
    {
        return esc_html__('Nightmode', 'uf-theme');
    }

    // MARK: Get Keywords
    public function get_keywords()
    {
        return ['mode', 'nightmode', 'switcher', 'dark', 'theme', 'light'];
    }

    // MARK: Set Controls
    public function set_controls()
    {
        $this->controls['cookieExpiry'] = [
            'label' => esc_html__('Cookie Expiry', 'uf-theme'),
            'type' => 'number',
            'units' => false,
            'min' => 1,
            'max' => 365,
            'step' => 1,
            'default' => 365,
            'placeholder' => esc_html__('365', 'uf-theme'),
        ];

        $this->controls['respectSystem'] = [
            'label' => esc_html__('Respect System Preference', 'uf-theme'),
            'type' => 'checkbox',
            'default' => true,
        ];
    }

    // MARK: Enqueue Scripts
    public function enqueue_scripts()
    {
        if (bricks_is_builder_main()) return;

        wp_enqueue_style($this->name, $this->cssURL, [$this->themeStyles], filemtime($this->cssPath));
        if (bricks_is_frontend()) {
            wp_enqueue_script($this->name, $this->jsURL, [$this->themeScripts], filemtime($this->jsPath), true);

            // Get the current preference from server
            $current_preference = Nightmode::get_preference();

            wp_localize_script($this->name, 'ufNightmode', [
                'ajax_url' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce('uf-nightmode-nonce'),
                'initial_mode' => $current_preference,
                'is_user_logged_in' => is_user_logged_in(),
                'has_saved_preference' => $this->has_saved_preference(),
            ]);
        }
    }

    // MARK: Render
    public function render()
    {
        $selector = $this->name;
        $root_class[] = $selector;
        $settings = $this->settings;

        $cookie_expiry = !empty($settings['cookieExpiry']) ? intval($settings['cookieExpiry']) : 365;
        $respect_system = isset($settings['respectSystem']) ? 'true' : 'false';

        $nightmode = Nightmode::get_preference();

        $this->set_attribute('_root', 'class', $root_class);
        $this->set_attribute('_root', 'data-mode', esc_attr($nightmode));
        $this->set_attribute('_root', 'data-respect-system', $respect_system);
        $this->set_attribute('_root', 'data-cookie-expiry', $cookie_expiry);
        $this->set_attribute('_root', 'aria-label', esc_attr__('Toggle Nightmode', 'uf-theme'));

        $svgSun = Helpers::file_get_contents(dirname(__FILE__) . '/svg/lucide-sun.svg');
        $svgMoon = Helpers::file_get_contents(dirname(__FILE__) . '/svg/lucide-moon.svg');

        $svgSun = $this->render_svg($svgSun, ['class' => 'sun']);
        $svgMoon = $this->render_svg($svgMoon, ['class' => 'moon']);

        $output = "<{$this->tag} {$this->render_attributes('_root')}>";
        $output .= $svgSun;
        $output .= $svgMoon;
        $output .= "</{$this->tag}>";

        echo $output;
    }
    // MARK: Check if user has saved preference
    private function has_saved_preference()
    {
        if (is_user_logged_in()) {
            $user_id = get_current_user_id();
            $preference = get_user_meta($user_id, Nightmode::$user_meta_key, true);
            return !empty($preference);
        } else {
            return isset($_COOKIE['uf_nightmode']);
        }
    }
}

Ajax Class:

<?php

namespace Userfreunde\Ajax;

if (! defined('ABSPATH')) exit; // Exit if accessed directly

class Nightmode
{
	public static $default_mode = 'light';
	public static $user_meta_key = 'uf_nightmode_preference';

	public function __construct()
	{
		add_action('wp_ajax_uf_set_nightmode', [$this, 'toggle']);
		add_action('wp_ajax_nopriv_uf_set_nightmode', [$this, 'toggle']);

		add_filter('bricks/body/attributes', [$this, 'modify_body_attr']);
	}

	/**
	 * MARK: Toggle
	 */
	public function toggle(): void
	{
		if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'uf-nightmode-nonce')) {
			wp_send_json_error('Invalid nonce');
		}

		$mode = isset($_POST['mode']) ? sanitize_text_field($_POST['mode']) : self::$default_mode;

		// Validate mode value
		if (!in_array($mode, ['light', 'dark'])) {
			$mode = self::$default_mode;
		}

		if (is_user_logged_in()) {
			$this->set_user_preference($mode);

			wp_send_json_success([
				'message' => esc_html__('Nightmode preference saved (user)', 'uf-theme'),
				'mode' => $mode
			]);
			return;
		}

		// Set cookie with proper parameters to match JavaScript
		setcookie('uf_nightmode', $mode, [
			'expires' => time() + (365 * DAY_IN_SECONDS), // Match the default 365 days from JS
			'path' => '/',
			'samesite' => 'Lax'
		]);

		wp_send_json_success([
			'message' => esc_html__('Nightmode preference saved (cookie)', 'uf-theme'),
			'mode' => $mode
		]);
	}

	/**
	 * MARK: Modify Body Attribute
	 */
	public function modify_body_attr(array $attributes): array
	{
		$preference = self::get_preference();

		$attributes['data-nightmode'] = $preference;

		return $attributes;
	}

	/**
	 * MARK: Get Preference
	 */
	public static function get_preference(): string
	{
		if (is_user_logged_in()) {
			$preference = self::get_user_preference();
		} else if (isset($_COOKIE['uf_nightmode']) && in_array($_COOKIE['uf_nightmode'], ['dark', 'light'])) {
			$preference = $_COOKIE['uf_nightmode'];
		} else {
			$preference = self::$default_mode;
		}

		return $preference;
	}

	/**
	 * MARK: Get User Preference
	 */
	public static function get_user_preference(): string
	{
		$user_id = get_current_user_id();

		$preference = get_user_meta($user_id, self::$user_meta_key, true);

		if (empty($preference) || !in_array($preference, ['light', 'dark'])) {
			$preference = self::$default_mode;
		}

		return $preference;
	}

	/**
	 * MARK: Set User Preference
	 */
	public function set_user_preference(string $mode): void
	{
		$user_id = get_current_user_id();

		update_user_meta($user_id, self::$user_meta_key, $mode);
	}
}

Javascript

class UfNightmode {
	constructor(element) {
		this.element = element;
		this.selector = element.dataset.selector || 'uf-nightmode';
		this.body = document.body;
		this.currentMode = 'light';

		this.init();
	}

	init() {
		this.loadInitialMode();
		this.setupEventListeners();
	}

	loadInitialMode() {
		// Use server-provided initial mode
		if (typeof ufNightmode !== 'undefined' && ufNightmode.initial_mode) {
			this.setMode(ufNightmode.initial_mode);
		} else {
			// Fallback to client-side detection
			this.checkSystemPreference();
			this.checkCookie();
		}
	}

	/**
	 * Cache DOM elements for performance
	 */
	cacheElements() {
		// Elements are queried globally for this special component
		this.buttons = document.querySelectorAll(`.${this.selector}`);
	}

	setupEventListeners() {
		this.element.addEventListener('click', () => this.toggleMode());
	}

	checkSystemPreference() {
		// Only check if no saved preference and element allows it
		if (typeof ufNightmode !== 'undefined' && !ufNightmode.has_saved_preference) {
			const respectSystem = this.element.getAttribute('data-respect-system') === 'true';

			if (respectSystem && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
				this.setMode('dark');
			}
		}
	}

	checkCookie() {
		// Only check cookie if not logged in and no server preference
		if (
			typeof ufNightmode !== 'undefined' &&
			!ufNightmode.is_user_logged_in &&
			!ufNightmode.initial_mode
		) {
			const mode = this.getCookie('uf_nightmode');
			if (mode && (mode === 'light' || mode === 'dark')) {
				this.setMode(mode);
			}
		}
	}

	toggleMode() {
		const newMode = this.currentMode === 'light' ? 'dark' : 'light';
		const cookieExpiry = this.element.getAttribute('data-cookie-expiry') || 365;

		// Set cookie if user is not logged in
		if (typeof ufNightmode === 'undefined' || !ufNightmode.is_user_logged_in) {
			this.setCookie('uf_nightmode', newMode, cookieExpiry);
		}

		this.setMode(newMode);
		this.sendAjaxRequest(newMode);
	}

	setMode(mode) {
		this.currentMode = mode;

		// Update body (data attribute handles styling)
		this.body.setAttribute('data-nightmode', mode);

		// Update all buttons globally
		document.querySelectorAll(`.${this.selector}`).forEach((button) => {
			button.setAttribute('data-mode', mode);
		});

		// Dispatch custom event
		document.dispatchEvent(
			new CustomEvent('ufNightmodeChanged', {
				detail: { mode },
				bubbles: true,
			})
		);
	}

	setCookie(name, value, days) {
		const date = new Date();
		date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
		const expires = `; expires=${date.toUTCString()}`;

		document.cookie = `${name}=${value}${expires}; path=/; SameSite=Lax`;
	}

	getCookie(name) {
		const nameEQ = `${name}=`;
		const cookies = document.cookie.split(';');

		for (let cookie of cookies) {
			cookie = cookie.trim();
			if (cookie.indexOf(nameEQ) === 0) {
				return cookie.substring(nameEQ.length);
			}
		}

		return null;
	}

	sendAjaxRequest(mode) {
		if (typeof ufNightmode === 'undefined' || !ufNightmode.ajax_url) return;

		const formData = new FormData();
		formData.append('action', 'uf_set_nightmode');
		formData.append('mode', mode);
		formData.append('nonce', ufNightmode.nonce);

		fetch(ufNightmode.ajax_url, {
			method: 'POST',
			body: formData,
		})
			.then((response) => response.json())
			.then((data) => {
				if (!data.success) {
					console.error('Error saving nightmode preference:', data.data);
				}
			})
			.catch((error) => {
				console.error('Error saving nightmode preference:', error);
			});
	}
}

// MARK: Initialization

const ufNightmodeFn = new BricksFunction({
	parentNode: document,
	selector: '.uf-nightmode',
	eachElement: (element) => {
		if (element.dataset.initialized === 'true') return;

		new UfNightmode(element);
		element.dataset.initialized = 'true';
	},
});

function ufNightmode() {
	ufNightmodeFn.run();
}

ufNightmode();

Also some Nightmodes respect User Preferences (which some people set dynamically depending on the hour of the day). this would also be a need feature, but would require a Select-Input instead of a switch :wink: I didn’t get to implement it yet aswell..

Best Regards
Suat

Hi Suat,
Thanks so much for your report!

As far as I can see, both issues have already been reported, and should be fixed in the next version.

#1 WIP: The Toggle - Mode element is not fully clickable
#2 WIP: Delay in detecting Default mode Dark/Light mode

Best regards,
timmse

1 Like