diff --git a/plugins/view-transitions/css/validator-selector.css b/plugins/view-transitions/css/validator-selector.css
new file mode 100644
index 0000000000..c680f1e35c
--- /dev/null
+++ b/plugins/view-transitions/css/validator-selector.css
@@ -0,0 +1,34 @@
+/**
+ * CSS Selector Validation Styles
+ *
+ * Styles for visual feedback of CSS selector validation.
+ *
+ * @since n.e.x.t
+ */
+
+/* Valid selector indicator */
+input.plvt-selector-valid {
+ border-color: #46b450 !important;
+ background-color: #f0fdf4;
+}
+
+input.plvt-selector-valid:focus {
+ border-color: #46b450 !important;
+}
+
+/* Invalid selector indicator */
+input.plvt-selector-invalid {
+ border-color: #dc2626 !important;
+ background-color: #fdf2f2;
+}
+
+input.plvt-selector-invalid:focus {
+ border-color: #dc2626 !important;
+}
+
+/* Error message styling */
+.plvt-selector-error {
+ color: #dc2626;
+ margin-top: 5px;
+ font-style: italic;
+}
diff --git a/plugins/view-transitions/hooks.php b/plugins/view-transitions/hooks.php
index 260cc7381a..b57106ec36 100644
--- a/plugins/view-transitions/hooks.php
+++ b/plugins/view-transitions/hooks.php
@@ -39,4 +39,5 @@ function plvt_render_generator(): void {
add_action( 'init', 'plvt_register_setting' );
add_action( 'init', 'plvt_apply_settings_to_theme_support' );
add_action( 'load-options-reading.php', 'plvt_add_setting_ui' );
+add_action( 'admin_enqueue_scripts', 'plvt_enqueue_selector_validation' );
add_filter( 'plugin_action_links_' . plugin_basename( VIEW_TRANSITIONS_MAIN_FILE ), 'plvt_add_settings_action_link' );
diff --git a/plugins/view-transitions/includes/admin.php b/plugins/view-transitions/includes/admin.php
index 83e81e45e3..1e00401da3 100644
--- a/plugins/view-transitions/includes/admin.php
+++ b/plugins/view-transitions/includes/admin.php
@@ -24,13 +24,51 @@
*/
function plvt_print_view_transitions_admin_style(): void {
$options = plvt_get_stored_setting_value();
- if ( ! isset( $options['enable_admin_transitions'] ) || true !== $options['enable_admin_transitions'] ) {
+ if ( ! isset( $options['enable_admin_transitions'] ) || ! (bool) $options['enable_admin_transitions'] ) {
return;
}
+
+ $duration = absint( $options['default_transition_animation_duration'] );
?>
id ) {
+ return;
+ }
+
+ // Enqueue validation CSS.
+ wp_enqueue_style(
+ 'plvt-selector-validator',
+ plugin_dir_url( VIEW_TRANSITIONS_MAIN_FILE ) . 'css/validator-selector.css',
+ array(),
+ VIEW_TRANSITIONS_VERSION
+ );
+
+ // Enqueue validation JS.
+ wp_enqueue_script(
+ 'plvt-selector-validator',
+ plugin_dir_url( VIEW_TRANSITIONS_MAIN_FILE ) . 'js/validator-selector.js',
+ array(),
+ VIEW_TRANSITIONS_VERSION,
+ array( 'in_footer' => false )
+ );
+}
diff --git a/plugins/view-transitions/includes/settings.php b/plugins/view-transitions/includes/settings.php
index 4242dfc6de..51fe736bba 100644
--- a/plugins/view-transitions/includes/settings.php
+++ b/plugins/view-transitions/includes/settings.php
@@ -10,6 +10,22 @@
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
+
+/**
+ * Minimum allowed transition animation duration in milliseconds.
+ *
+ * @since n.e.x.t
+ * @var int
+ */
+const PLVT_MIN_ANIMATION_DURATION = 100;
+
+/**
+ * Maximum allowed transition animation duration in milliseconds.
+ *
+ * @since n.e.x.t
+ * @var int
+ */
+const PLVT_MAX_ANIMATION_DURATION = 5000;
// @codeCoverageIgnoreEnd
/**
@@ -135,9 +151,11 @@ function plvt_sanitize_setting( $input ): array {
$value['default_transition_animation'] = $input['default_transition_animation'];
}
- // Handle default_transition_animation_duration separately.
+ // Handle default_transition_animation_duration with min/max bounds.
if ( isset( $input['default_transition_animation_duration'] ) ) {
- $value['default_transition_animation_duration'] = absint( $input['default_transition_animation_duration'] );
+ $duration = absint( $input['default_transition_animation_duration'] );
+ // Clamp between min and max for sensible values.
+ $value['default_transition_animation_duration'] = max( PLVT_MIN_ANIMATION_DURATION, min( PLVT_MAX_ANIMATION_DURATION, $duration ) );
}
$selector_options = array(
@@ -193,6 +211,12 @@ function plvt_register_setting(): void {
'type' => 'string',
'enum' => array_keys( plvt_get_view_transition_animation_labels() ),
),
+ 'default_transition_animation_duration' => array(
+ 'description' => __( 'Duration of the view transition animation in milliseconds.', 'view-transitions' ),
+ 'type' => 'integer',
+ 'minimum' => PLVT_MIN_ANIMATION_DURATION,
+ 'maximum' => PLVT_MAX_ANIMATION_DURATION,
+ ),
),
'additionalProperties' => false,
),
@@ -328,6 +352,10 @@ static function (): void {
'section' => 'plvt_view_transitions',
'title' => __( 'Transition Animation Duration', 'view-transitions' ),
'description' => __( 'Control the duration of the view transition. Enter the value in milliseconds (e.g., 500, 1000, 2000).', 'view-transitions' ),
+ 'min' => PLVT_MIN_ANIMATION_DURATION,
+ 'max' => PLVT_MAX_ANIMATION_DURATION,
+ 'step' => 50,
+ 'unit' => 'ms',
),
'header_selector' => array(
'section' => 'plvt_view_transitions',
@@ -473,15 +501,68 @@ function plvt_render_settings_field( array $args ): void {
+ "
+ value=""
+ class="small-text"
+
+ min=""
+
+ max=""
+
+ step=""
+
+ aria-describedby=""
+
+ >
+
+
+
id=""
name=""
value=""
class="regular-text code"
+ data-plvt-validate-selector
+
aria-describedby=""
diff --git a/plugins/view-transitions/js/validator-selector.js b/plugins/view-transitions/js/validator-selector.js
new file mode 100644
index 0000000000..43fd7f7d3b
--- /dev/null
+++ b/plugins/view-transitions/js/validator-selector.js
@@ -0,0 +1,110 @@
+/**
+ * CSS Selector Validation for View Transitions Settings
+ *
+ * This script provides real-time validation for CSS selector input fields
+ * in the View Transitions settings panel.
+ *
+ * @since n.e.x.t
+ */
+
+( () => {
+ /**
+ * Validates a CSS selector by attempting to use it with document.querySelector.
+ *
+ * @param {string} selector The CSS selector to validate.
+ * @return {Object} Object with 'valid' boolean and optional 'message' string.
+ */
+ function validateSelector( selector ) {
+ // Empty selectors are allowed (they reset to default)
+ if ( '' === selector.trim() ) {
+ return { valid: true };
+ }
+
+ try {
+ document.querySelector( selector );
+ return { valid: true };
+ } catch ( error ) {
+ return {
+ valid: false,
+ message: 'Invalid CSS selector: ' + error.message,
+ };
+ }
+ }
+
+ /**
+ * Sets custom validity for a selector input field.
+ *
+ * @param {HTMLInputElement} input The input element to validate.
+ */
+ function updateValidation( input ) {
+ const result = validateSelector( input.value );
+
+ if ( result.valid ) {
+ input.setCustomValidity( '' );
+ input.classList.remove( 'plvt-selector-invalid' );
+ input.classList.add( 'plvt-selector-valid' );
+
+ // Remove any existing error message
+ const existingError = input.parentNode.querySelector(
+ '.plvt-selector-error'
+ );
+ if ( existingError ) {
+ existingError.remove();
+ }
+ } else {
+ input.setCustomValidity( result.message );
+ input.classList.remove( 'plvt-selector-valid' );
+ input.classList.add( 'plvt-selector-invalid' );
+
+ // Show error message
+ let errorElement = input.parentNode.querySelector(
+ '.plvt-selector-error'
+ );
+ if ( ! errorElement ) {
+ errorElement = document.createElement( 'p' );
+ errorElement.className = 'plvt-selector-error description';
+ input.parentNode.insertBefore(
+ errorElement,
+ input.nextSibling
+ );
+ }
+ errorElement.textContent = result.message;
+ }
+ }
+
+ /**
+ * Initializes validation for all selector input fields.
+ */
+ function initValidation() {
+ // Target all text inputs for selectors
+ const selectorInputs = document.querySelectorAll(
+ 'input[data-plvt-validate-selector]'
+ );
+
+ selectorInputs.forEach( ( element ) => {
+ const input = /** @type {HTMLInputElement} */ ( element );
+
+ // Validate on blur
+ input.addEventListener( 'blur', () => {
+ updateValidation( input );
+ } );
+
+ // Validate on input for real-time feedback
+ input.addEventListener( 'input', () => {
+ updateValidation( input );
+ } );
+
+ // Validate on page load if field has a value
+ if ( '' !== input.value.trim() ) {
+ updateValidation( input );
+ }
+ } );
+ }
+
+ // Initialize when DOM is ready
+ if ( 'loading' === document.readyState ) {
+ document.addEventListener( 'DOMContentLoaded', initValidation );
+ } else {
+ initValidation();
+ }
+} )();
diff --git a/plugins/view-transitions/tests/test-admin.php b/plugins/view-transitions/tests/test-admin.php
new file mode 100644
index 0000000000..6c731d6e32
--- /dev/null
+++ b/plugins/view-transitions/tests/test-admin.php
@@ -0,0 +1,60 @@
+ false ) );
+
+ ob_start();
+ plvt_print_view_transitions_admin_style();
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+
+ /**
+ * @covers ::plvt_print_view_transitions_admin_style
+ */
+ public function test_plvt_print_view_transitions_admin_style_enabled(): void {
+ update_option(
+ 'plvt_view_transitions',
+ array(
+ 'enable_admin_transitions' => true,
+ 'default_transition_animation_duration' => 500,
+ )
+ );
+
+ ob_start();
+ plvt_print_view_transitions_admin_style();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( '@view-transition { navigation: auto; }', $output );
+ $this->assertStringContainsString( '--plvt-view-transition-animation-duration: 500ms', $output );
+ }
+
+ /**
+ * @covers ::plvt_print_view_transitions_admin_style
+ */
+ public function test_plvt_print_view_transitions_admin_style_uses_default_duration(): void {
+ update_option(
+ 'plvt_view_transitions',
+ array( 'enable_admin_transitions' => true )
+ );
+
+ ob_start();
+ plvt_print_view_transitions_admin_style();
+ $output = ob_get_clean();
+
+ // Default duration is 400ms.
+ $this->assertStringContainsString( '--plvt-view-transition-animation-duration: 400ms', $output );
+ }
+}
diff --git a/plugins/view-transitions/tests/test-settings.php b/plugins/view-transitions/tests/test-settings.php
new file mode 100644
index 0000000000..93954fce1c
--- /dev/null
+++ b/plugins/view-transitions/tests/test-settings.php
@@ -0,0 +1,89 @@
+assertSame( plvt_get_setting_default(), plvt_sanitize_setting( null ) );
+ $this->assertSame( plvt_get_setting_default(), plvt_sanitize_setting( 'string' ) );
+ $this->assertSame( plvt_get_setting_default(), plvt_sanitize_setting( 123 ) );
+ }
+
+ /**
+ * @covers ::plvt_sanitize_setting
+ */
+ public function test_plvt_sanitize_setting_clamps_duration_minimum(): void {
+ $input = array( 'default_transition_animation_duration' => 50 );
+ $result = plvt_sanitize_setting( $input );
+ $this->assertSame( PLVT_MIN_ANIMATION_DURATION, $result['default_transition_animation_duration'] );
+ }
+
+ /**
+ * @covers ::plvt_sanitize_setting
+ */
+ public function test_plvt_sanitize_setting_clamps_duration_maximum(): void {
+ $input = array( 'default_transition_animation_duration' => 10000 );
+ $result = plvt_sanitize_setting( $input );
+ $this->assertSame( PLVT_MAX_ANIMATION_DURATION, $result['default_transition_animation_duration'] );
+ }
+
+ /**
+ * @covers ::plvt_sanitize_setting
+ */
+ public function test_plvt_sanitize_setting_accepts_valid_duration(): void {
+ $input = array( 'default_transition_animation_duration' => 500 );
+ $result = plvt_sanitize_setting( $input );
+ $this->assertSame( 500, $result['default_transition_animation_duration'] );
+ }
+
+ /**
+ * @covers ::plvt_sanitize_setting
+ */
+ public function test_plvt_sanitize_setting_handles_negative_duration(): void {
+ $input = array( 'default_transition_animation_duration' => -500 );
+ $result = plvt_sanitize_setting( $input );
+ // absint converts negative to positive, then clamps.
+ $this->assertSame( 500, $result['default_transition_animation_duration'] );
+ }
+
+ /**
+ * @covers ::plvt_sanitize_setting
+ */
+ public function test_plvt_sanitize_setting_handles_string_duration(): void {
+ $input = array( 'default_transition_animation_duration' => '750' );
+ $result = plvt_sanitize_setting( $input );
+ $this->assertSame( 750, $result['default_transition_animation_duration'] );
+ }
+
+ /**
+ * @covers ::plvt_get_setting_default
+ */
+ public function test_plvt_get_setting_default_has_valid_duration(): void {
+ $defaults = plvt_get_setting_default();
+ $this->assertArrayHasKey( 'default_transition_animation_duration', $defaults );
+ $this->assertIsInt( $defaults['default_transition_animation_duration'] );
+ $this->assertGreaterThanOrEqual( PLVT_MIN_ANIMATION_DURATION, $defaults['default_transition_animation_duration'] );
+ $this->assertLessThanOrEqual( PLVT_MAX_ANIMATION_DURATION, $defaults['default_transition_animation_duration'] );
+ }
+
+ /**
+ * @covers ::plvt_sanitize_setting
+ */
+ public function test_plvt_sanitize_setting_validates_animation_type(): void {
+ $input = array( 'default_transition_animation' => 'invalid-animation' );
+ $result = plvt_sanitize_setting( $input );
+ $this->assertSame( 'fade', $result['default_transition_animation'] );
+
+ $input = array( 'default_transition_animation' => 'slide-from-right' );
+ $result = plvt_sanitize_setting( $input );
+ $this->assertSame( 'slide-from-right', $result['default_transition_animation'] );
+ }
+}
diff --git a/plugins/view-transitions/tests/test-theme.php b/plugins/view-transitions/tests/test-theme.php
index 7879c72f73..c675ccd903 100644
--- a/plugins/view-transitions/tests/test-theme.php
+++ b/plugins/view-transitions/tests/test-theme.php
@@ -48,4 +48,36 @@ public function test_plvt_load_view_transitions(): void {
$this->assertTrue( wp_style_is( 'plvt-view-transitions', 'registered' ) );
$this->assertTrue( wp_style_is( 'plvt-view-transitions', 'enqueued' ) );
}
+
+ /**
+ * @covers ::plvt_inject_animation_duration
+ */
+ public function test_plvt_inject_animation_duration_with_existing_css(): void {
+ $css = '::view-transition-old(*) { animation-name: test; }';
+ $result = plvt_inject_animation_duration( $css, 500 );
+
+ $this->assertStringContainsString( '--plvt-view-transition-animation-duration: 0.5s', $result );
+ $this->assertStringContainsString( $css, $result );
+ }
+
+ /**
+ * @covers ::plvt_inject_animation_duration
+ */
+ public function test_plvt_inject_animation_duration_with_empty_css(): void {
+ $result = plvt_inject_animation_duration( '', 400 );
+
+ $this->assertStringContainsString( 'animation-duration: 0.4s', $result );
+ $this->assertStringNotContainsString( '--plvt-view-transition-animation-duration', $result );
+ }
+
+ /**
+ * @covers ::plvt_inject_animation_duration
+ */
+ public function test_plvt_inject_animation_duration_converts_milliseconds_to_seconds(): void {
+ $result = plvt_inject_animation_duration( '', 1000 );
+ $this->assertStringContainsString( '1s', $result );
+
+ $result = plvt_inject_animation_duration( '', 250 );
+ $this->assertStringContainsString( '0.25s', $result );
+ }
}