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 ); + } }