-
+
-
+
-
+
-
+
-
+
diff --git a/inc/Plugin.php b/inc/Plugin.php
index 75d5c7e..a296e70 100644
--- a/inc/Plugin.php
+++ b/inc/Plugin.php
@@ -39,22 +39,23 @@ protected function setup_hooks(): void {
add_filter( 'block_categories_all', [ $this, 'register_block_category' ] );
add_action( 'init', [ $this, 'register_pattern_category' ] );
add_action( 'init', [ $this, 'register_block_patterns' ] );
- add_action( 'admin_init', [ $this, 'deactivate_legacy_plugin' ] );
+ add_action( 'admin_notices', [ $this, 'legacy_plugin_notice' ] );
+ add_action( 'network_admin_notices', [ $this, 'legacy_plugin_notice' ] );
}
/**
- * Deactivate the legacy "Carousel Kit" plugin if still active.
+ * Show an admin notice if the legacy "Carousel Kit" plugin is still active.
*
* Handles both single-site and network-wide activations.
*/
- public function deactivate_legacy_plugin(): void {
- $old_plugin = 'carousel-kit/carousel-kit.php';
+ public function legacy_plugin_notice(): void {
+ $old_plugin = 'carousel-kit/carousel-kit.php';
+ $network_wide = is_multisite() && is_plugin_active_for_network( $old_plugin );
if ( ! is_plugin_active( $old_plugin ) ) {
return;
}
- $network_wide = is_multisite() && is_plugin_active_for_network( $old_plugin );
if ( $network_wide && ! current_user_can( 'manage_network_plugins' ) ) {
return;
}
@@ -63,21 +64,41 @@ public function deactivate_legacy_plugin(): void {
return;
}
- // Silent flag prevents deactivation hooks from firing redirect.
- deactivate_plugins( $old_plugin, true, $network_wide );
-
- if ( is_plugin_active( $old_plugin ) ) {
+ // Only show the notice in the matching admin context.
+ if ( is_network_admin() !== $network_wide ) {
return;
}
- add_action(
- 'admin_notices',
- static function (): void {
- printf(
- '
',
- esc_html__( 'The old "Carousel Kit" plugin has been deactivated. rtCarousel is its replacement.', 'rt-carousel' )
- );
- }
+ if ( $network_wide ) {
+ $deactivate_url = wp_nonce_url(
+ add_query_arg(
+ [
+ 'action' => 'deactivate',
+ 'plugin' => $old_plugin,
+ 'networkwide' => '1',
+ ],
+ network_admin_url( 'plugins.php' )
+ ),
+ 'deactivate-plugin_' . $old_plugin
+ );
+ } else {
+ $deactivate_url = wp_nonce_url(
+ add_query_arg(
+ [
+ 'action' => 'deactivate',
+ 'plugin' => $old_plugin,
+ ],
+ admin_url( 'plugins.php' )
+ ),
+ 'deactivate-plugin_' . $old_plugin
+ );
+ }
+
+ printf(
+ '
',
+ esc_html__( 'The "Carousel Kit" plugin is still active. rtCarousel is its replacement — please deactivate Carousel Kit.', 'rt-carousel' ),
+ esc_url( $deactivate_url ),
+ esc_html__( 'Deactivate Carousel Kit', 'rt-carousel' )
);
}
diff --git a/package-lock.json b/package-lock.json
index 9b4344c..53e6e50 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rt-carousel",
- "version": "2.0.0",
+ "version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rt-carousel",
- "version": "2.0.0",
+ "version": "2.0.1",
"dependencies": {
"@wordpress/babel-preset-default": "8.38.0",
"@wordpress/block-editor": "^15.10.0",
diff --git a/package.json b/package.json
index ec336da..cf8ce34 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rt-carousel",
- "version": "2.0.0",
+ "version": "2.0.1",
"description": "Carousel block using Embla and WordPress Interactivity API",
"author": "rtCamp",
"private": true,
diff --git a/readme.txt b/readme.txt
index adec6b2..78216b9 100644
--- a/readme.txt
+++ b/readme.txt
@@ -4,7 +4,7 @@ Tags: carousel, slider, block, interactivity-api, embla
Requires at least: 6.6
Tested up to: 6.9
Requires PHP: 8.2
-Stable tag: 2.0.0
+Stable tag: 2.0.1
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -77,7 +77,7 @@ Yes. Each carousel instance maintains its own independent state.
= I am using "Carousel Kit". How do I upgrade to rtCarousel? =
-rtCarousel is the successor to Carousel Kit. Simply install and activate rtCarousel — it will automatically migrate all existing carousel blocks in your content and deactivate the old plugin. No manual steps are needed. You can safely delete the old Carousel Kit plugin afterward.
+rtCarousel is the successor to Carousel Kit. Simply install and activate rtCarousel — it will automatically migrate all existing carousel blocks in your content. You will see an admin notice prompting you to deactivate the old Carousel Kit plugin. After deactivating it, you can safely delete it.
== Screenshots ==
@@ -85,6 +85,12 @@ rtCarousel is the successor to Carousel Kit. Simply install and activate rtCarou
== Changelog ==
+= 2.0.1 =
+* New: Add a11y announcements for carousel slide changes
+* Fix: Carousel dot focus loss with VoiceOver activation
+* Refactor: Replace automatic plugin deactivation with an admin notice
+
+
= 2.0.0 =
* New: Carousel progress bar block
* New: Vertical alignment support for carousel slides
@@ -129,4 +135,4 @@ rtCarousel is the successor to Carousel Kit. Simply install and activate rtCarou
== Upgrade Notice ==
= 2.0.0 =
-Plugin renamed from "Carousel Kit" to "rtCarousel". Existing carousel blocks are automatically migrated on activation. The old Carousel Kit plugin is deactivated automatically and can be safely deleted.
+Plugin renamed from "Carousel Kit" to "rtCarousel". Existing carousel blocks are automatically migrated on activation. You will see an admin notice prompting you to deactivate the old Carousel Kit plugin, which can then be safely deleted.
diff --git a/rt-carousel.php b/rt-carousel.php
index a7a2452..2f7ae77 100644
--- a/rt-carousel.php
+++ b/rt-carousel.php
@@ -10,7 +10,7 @@
* Domain Path: /languages
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
- * Version: 2.0.0
+ * Version: 2.0.1
* Text Domain: rt-carousel
*
* @package rt-carousel
diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts
index d8e6ae4..d7aa624 100644
--- a/src/blocks/carousel/__tests__/view.test.ts
+++ b/src/blocks/carousel/__tests__/view.test.ts
@@ -336,7 +336,9 @@ describe( 'Carousel View Module', () => {
setEmblaOnViewport( viewport, mockEmbla );
- const mockContext = createMockContext();
+ const mockContext = createMockContext( {
+ selectedIndex: 1,
+ } );
( mockContext as CarouselContext & { snap?: { index: number } } ).snap = {
index: 0,
};
@@ -694,6 +696,67 @@ describe( 'Carousel View Module', () => {
consoleErrorSpy.mockRestore();
} );
+
+ it( 'should update announcement after a manual slide change', () => {
+ const mockContext = createMockContext( {
+ announcementPattern: 'Slide {{currentSlide}} of {{totalSlides}}',
+ selectedIndex: -1,
+ } );
+ const { wrapper, viewport } = createMockCarouselDOM();
+ const listeners: {
+ select?: () => void;
+ } = {};
+ const selectedScrollSnap = jest
+ .fn()
+ .mockReturnValueOnce( 0 )
+ .mockReturnValueOnce( 1 );
+ const mockEmbla = createMockEmblaInstance( {
+ selectedScrollSnap,
+ scrollSnapList: jest.fn( () => [ 0, 1, 2, 3, 4 ] ),
+ slideNodes: jest.fn( () =>
+ Array.from( { length: 5 }, () => document.createElement( 'div' ) ),
+ ),
+ scrollProgress: jest.fn( () => 0.25 ),
+ } );
+ const originalIntersectionObserver = window.IntersectionObserver;
+
+ mockEmbla.on = jest.fn( ( eventName: string, callback: () => void ) => {
+ if ( eventName === 'select' ) {
+ listeners.select = callback;
+ }
+ return mockEmbla;
+ } );
+
+ viewport.getBoundingClientRect = jest.fn( () => ( {
+ width: 100,
+ height: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ x: 0,
+ y: 0,
+ toJSON: () => ( {} ),
+ } ) );
+
+ ( getContext as jest.Mock ).mockReturnValue( mockContext );
+ ( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } );
+ ( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla );
+ delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver;
+
+ try {
+ storeConfig.callbacks.initCarousel();
+
+ mockContext.shouldAnnounce = true;
+ listeners.select?.();
+
+ expect( mockContext.announcement ).toBe( 'Slide 2 of 5' );
+ expect( mockContext.shouldAnnounce ).toBe( false );
+ } finally {
+ ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver =
+ originalIntersectionObserver;
+ }
+ } );
} );
} );
} );
diff --git a/src/blocks/carousel/deprecated.tsx b/src/blocks/carousel/deprecated.tsx
new file mode 100644
index 0000000..2107d9a
--- /dev/null
+++ b/src/blocks/carousel/deprecated.tsx
@@ -0,0 +1,121 @@
+import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
+import { __ } from '@wordpress/i18n';
+import type { CarouselAttributes } from './types';
+
+/**
+ * v2.0.0 save — before a11y announcement fields were added to context
+ * and the live-region
was appended.
+ *
+ * @param {Object} root0 Component props.
+ * @param {CarouselAttributes} root0.attributes Block attributes.
+ */
+function SaveV200( {
+ attributes,
+}: {
+ attributes: CarouselAttributes;
+} ) {
+ const {
+ loop,
+ dragFree,
+ carouselAlign,
+ containScroll,
+ direction,
+ autoplay,
+ autoplayDelay,
+ autoplayStopOnInteraction,
+ autoplayStopOnMouseEnter,
+ ariaLabel,
+ slideGap,
+ axis,
+ height,
+ slidesToScroll,
+ } = attributes;
+
+ const context = {
+ options: {
+ loop,
+ dragFree,
+ align: carouselAlign,
+ containScroll,
+ direction,
+ axis,
+ slidesToScroll: slidesToScroll === 'auto' ? 'auto' : parseInt( slidesToScroll, 10 ),
+ },
+ autoplay: autoplay
+ ? {
+ delay: autoplayDelay,
+ stopOnInteraction: autoplayStopOnInteraction,
+ stopOnMouseEnter: autoplayStopOnMouseEnter,
+ }
+ : false,
+ isPlaying: !! autoplay,
+ timerIterationId: 0,
+ selectedIndex: -1,
+ scrollSnaps: [] as { index: number }[],
+ canScrollPrev: false,
+ canScrollNext: false,
+ scrollProgress: 0,
+ slideCount: 0,
+ /* translators: %d: slide number */
+ ariaLabelPattern: __( 'Go to slide %d', 'rt-carousel' ),
+ };
+
+ const blockProps = useBlockProps.save( {
+ className: 'rt-carousel',
+ role: 'region',
+ 'aria-roledescription': 'carousel',
+ 'aria-label': ariaLabel,
+ dir: direction,
+ 'data-axis': axis,
+ 'data-loop': loop ? 'true' : undefined,
+ 'data-wp-interactive': 'rt-carousel/carousel',
+ 'data-wp-context': JSON.stringify( context ),
+ 'data-wp-init': 'callbacks.initCarousel',
+ style: {
+ '--rt-carousel-gap': `${ slideGap }px`,
+ '--rt-carousel-height': axis === 'y' ? height : undefined,
+ } as React.CSSProperties,
+ } );
+
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps ) as ReturnType<
+ typeof useInnerBlocksProps.save
+ > & {
+ children: React.ReactNode;
+ };
+
+ return
;
+}
+
+const deprecated = [
+ {
+ attributes: {
+ allowedSlideBlocks: { type: 'array' as const, default: [] },
+ loop: { type: 'boolean' as const, default: false },
+ dragFree: { type: 'boolean' as const, default: false },
+ carouselAlign: { type: 'string' as const, default: 'start' },
+ containScroll: { type: 'string' as const, default: 'trimSnaps' },
+ direction: { type: 'string' as const, default: 'ltr' },
+ axis: { type: 'string' as const, default: 'x' },
+ height: { type: 'string' as const, default: '300px' },
+ autoplay: { type: 'boolean' as const, default: false },
+ autoplayDelay: { type: 'number' as const, default: 4000 },
+ autoplayStopOnInteraction: { type: 'boolean' as const, default: true },
+ autoplayStopOnMouseEnter: { type: 'boolean' as const, default: false },
+ ariaLabel: { type: 'string' as const, default: 'Carousel' },
+ slideGap: { type: 'number' as const, default: 0 },
+ slidesToScroll: { type: 'string' as const, default: '1' },
+ },
+ supports: {
+ interactivity: true,
+ align: [ 'wide', 'full' ],
+ html: false,
+ color: {
+ text: false,
+ background: true,
+ },
+ },
+ save: SaveV200,
+ },
+];
+
+export default deprecated;
diff --git a/src/blocks/carousel/index.ts b/src/blocks/carousel/index.ts
index 118ec2d..5a9db43 100644
--- a/src/blocks/carousel/index.ts
+++ b/src/blocks/carousel/index.ts
@@ -5,6 +5,7 @@ import {
} from '@wordpress/blocks';
import Edit from './edit';
import Save from './save';
+import deprecated from './deprecated';
import './style.scss';
import './editor.scss';
import metadata from './block.json';
@@ -14,6 +15,7 @@ import { __ } from '@wordpress/i18n';
registerBlockType( metadata as BlockConfiguration, {
edit: Edit,
save: Save,
+ deprecated,
} );
const styles = [
diff --git a/src/blocks/carousel/save.tsx b/src/blocks/carousel/save.tsx
index 32849ef..907cc4d 100644
--- a/src/blocks/carousel/save.tsx
+++ b/src/blocks/carousel/save.tsx
@@ -52,6 +52,13 @@ export default function Save( {
slideCount: 0,
/* translators: %d: slide number */
ariaLabelPattern: __( 'Go to slide %d', 'rt-carousel' ),
+ announcement: '',
+ shouldAnnounce: false,
+ /* translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. */
+ announcementPattern: __(
+ 'Slide {{currentSlide}} of {{totalSlides}}',
+ 'rt-carousel',
+ ),
};
const blockProps = useBlockProps.save( {
@@ -71,7 +78,26 @@ export default function Save( {
} as React.CSSProperties,
} );
- const innerBlocksProps = useInnerBlocksProps.save( blockProps );
+ const innerBlocksProps = useInnerBlocksProps.save( blockProps ) as ReturnType<
+ typeof useInnerBlocksProps.save
+ > & {
+ children: React.ReactNode;
+ };
+ const { children, ...wrapperProps } = innerBlocksProps;
+ const announcementLiveRegion = (
+
+ );
- return
;
+ return (
+
+ { children }
+ { announcementLiveRegion }
+
+ );
}
diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts
index a12105b..d140a73 100644
--- a/src/blocks/carousel/types.ts
+++ b/src/blocks/carousel/types.ts
@@ -57,6 +57,9 @@ export type CarouselContext = {
canScrollNext: boolean;
scrollProgress: number;
ariaLabelPattern: string;
+ announcement?: string;
+ announcementPattern?: string;
+ shouldAnnounce?: boolean;
ref?: HTMLElement | null;
slideCount: number;
initialized?: boolean;
diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts
index cef1cc1..25f80c9 100644
--- a/src/blocks/carousel/view.ts
+++ b/src/blocks/carousel/view.ts
@@ -56,6 +56,42 @@ const getProgress = (): number => {
return Math.max( 0, Math.min( 1, scrollProgress || 0 ) );
};
+const getSlideAnnouncement = (
+ context: CarouselContext,
+ selectedIndex: number,
+ slideCount: number,
+): string => {
+ if ( ! slideCount || slideCount <= 1 || ! context.announcementPattern ) {
+ return '';
+ }
+ return context.announcementPattern
+ .replace( '{{currentSlide}}', ( selectedIndex + 1 ).toString() )
+ .replace( '{{totalSlides}}', slideCount.toString() );
+};
+
+const updateSlideAnnouncement = (
+ context: CarouselContext,
+ previousSelectedIndex: number,
+): void => {
+ if ( ! context.shouldAnnounce ) {
+ return;
+ }
+
+ if ( context.selectedIndex !== previousSelectedIndex ) {
+ context.announcement = getSlideAnnouncement(
+ context,
+ context.selectedIndex,
+ context.slideCount,
+ );
+ }
+
+ context.shouldAnnounce = false;
+};
+
+const markForAnnouncement = (): void => {
+ getContext().shouldAnnounce = true;
+};
+
store( 'rt-carousel/carousel', {
state: {
get canScrollPrev() {
@@ -72,6 +108,9 @@ store( 'rt-carousel/carousel', {
const element = getElementRef( getElement() );
const embla = getEmblaFromElement( element );
if ( embla ) {
+ if ( embla.canScrollPrev() ) {
+ markForAnnouncement();
+ }
embla.scrollPrev();
} else {
// eslint-disable-next-line no-console
@@ -82,6 +121,9 @@ store( 'rt-carousel/carousel', {
const element = getElementRef( getElement() );
const embla = getEmblaFromElement( element );
if ( embla ) {
+ if ( embla.canScrollNext() ) {
+ markForAnnouncement();
+ }
embla.scrollNext();
} else {
// eslint-disable-next-line no-console
@@ -98,6 +140,9 @@ store( 'rt-carousel/carousel', {
const element = getElementRef( getElement() );
const embla = getEmblaFromElement( element );
if ( embla ) {
+ if ( snap.index !== context.selectedIndex ) {
+ markForAnnouncement();
+ }
embla.scrollTo( snap.index );
}
}
@@ -237,15 +282,20 @@ store( 'rt-carousel/carousel', {
viewport[ EMBLA_KEY ] = embla;
const updateState = () => {
+ const previousSelectedIndex = context.selectedIndex;
+ const scrollSnapList = embla.scrollSnapList();
context.initialized = true;
context.canScrollPrev = embla.canScrollPrev();
context.canScrollNext = embla.canScrollNext();
context.selectedIndex = embla.selectedScrollSnap();
- context.scrollSnaps = embla
- .scrollSnapList()
- .map( ( _, index ) => ( { index } ) );
+ if ( context.scrollSnaps.length !== scrollSnapList.length ) {
+ context.scrollSnaps = scrollSnapList.map( ( _, index ) => ( {
+ index,
+ } ) );
+ }
context.scrollProgress = embla.scrollProgress();
context.slideCount = embla.slideNodes().length;
+ updateSlideAnnouncement( context, previousSelectedIndex );
};
embla.on( 'select', updateState );
diff --git a/tests/php/Unit/PluginTest.php b/tests/php/Unit/PluginTest.php
index 7a263bc..2477243 100644
--- a/tests/php/Unit/PluginTest.php
+++ b/tests/php/Unit/PluginTest.php
@@ -35,6 +35,7 @@ class PluginTest extends UnitTestCase {
'carousel',
'carousel/controls',
'carousel/dots',
+ 'carousel/progress',
'carousel/viewport',
'carousel/slide',
];
@@ -149,7 +150,7 @@ function ( string $path ) use ( &$registered_blocks ): void {
$instance = $this->getPluginInstance();
$this->invokeMethod( $instance, 'register_blocks' );
- $this->assertCount( 5, $registered_blocks );
+ $this->assertCount( 6, $registered_blocks );
// Verify each expected block is registered
foreach ( self::EXPECTED_BLOCKS as $block ) {
@@ -164,30 +165,6 @@ function ( string $path ) use ( &$registered_blocks ): void {
}
}
- /**
- * Test that blocks are registered in the correct order.
- *
- * @return void
- */
- public function test_register_blocks_in_correct_order(): void {
- $registered_blocks = [];
-
- Functions\when( 'register_block_type' )->alias(
- function ( string $path ) use ( &$registered_blocks ): void {
- $registered_blocks[] = $path;
- }
- );
-
- $instance = $this->getPluginInstance();
- $this->invokeMethod( $instance, 'register_blocks' );
-
- $this->assertStringContainsString( '/blocks/carousel', $registered_blocks[0] );
- $this->assertStringContainsString( '/blocks/carousel/controls', $registered_blocks[1] );
- $this->assertStringContainsString( '/blocks/carousel/dots', $registered_blocks[2] );
- $this->assertStringContainsString( '/blocks/carousel/viewport', $registered_blocks[3] );
- $this->assertStringContainsString( '/blocks/carousel/slide', $registered_blocks[4] );
- }
-
/**
* Test that register_blocks does nothing when build path is not defined.
*
@@ -196,7 +173,7 @@ function ( string $path ) use ( &$registered_blocks ): void {
public function test_register_blocks_handles_missing_build_path(): void {
// The actual behavior check: register_block_type should be called
// for each block when the constant is defined (as it is in our tests).
- Functions\expect( 'register_block_type' )->times( 5 );
+ Functions\expect( 'register_block_type' )->times( 6 );
$instance = $this->getPluginInstance();
$this->invokeMethod( $instance, 'register_blocks' );
@@ -407,4 +384,167 @@ public function test_register_block_patterns_handles_invalid_structure(): void {
// Assert completed successfully
$this->assertTrue( true );
}
+
+ /**
+ * Test that legacy_plugin_notice outputs nothing when old plugin is inactive.
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_no_output_when_inactive(): void {
+ Functions\expect( 'is_multisite' )->andReturn( false );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( false );
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+
+ /**
+ * Test that legacy_plugin_notice outputs nothing when user lacks capability (single-site).
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_no_output_without_capability(): void {
+ Functions\expect( 'is_multisite' )->andReturn( false );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'current_user_can' )->once()->with( 'activate_plugins' )->andReturn( false );
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+
+ /**
+ * Test that legacy_plugin_notice renders notice with deactivation link (single-site).
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_renders_on_single_site(): void {
+ Functions\expect( 'is_multisite' )->andReturn( false );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'current_user_can' )->once()->with( 'activate_plugins' )->andReturn( true );
+ Functions\expect( 'is_network_admin' )->andReturn( false );
+ Functions\expect( 'admin_url' )->once()->with( 'plugins.php' )->andReturn( 'https://example.com/wp-admin/plugins.php' );
+ Functions\expect( 'add_query_arg' )->once()->andReturnUsing(
+ function ( array $args, string $url ): string {
+ return $url . '?' . http_build_query( $args );
+ }
+ );
+ Functions\expect( 'wp_nonce_url' )->once()->andReturnFirstArg();
+ Functions\when( 'esc_html__' )->returnArg();
+ Functions\when( 'esc_url' )->returnArg();
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'notice-warning', $output );
+ $this->assertStringContainsString( 'Carousel Kit', $output );
+ $this->assertStringContainsString( 'action=deactivate', $output );
+ $this->assertStringNotContainsString( 'networkwide=1', $output );
+ }
+
+ /**
+ * Test that legacy_plugin_notice outputs nothing on multisite without manage_network_plugins.
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_no_output_without_network_capability(): void {
+ Functions\expect( 'is_multisite' )->andReturn( true );
+ Functions\expect( 'is_plugin_active_for_network' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'current_user_can' )->once()->with( 'manage_network_plugins' )->andReturn( false );
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+
+ /**
+ * Test that legacy_plugin_notice renders with network deactivation URL on multisite.
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_renders_network_url_on_multisite(): void {
+ Functions\expect( 'is_multisite' )->andReturn( true );
+ Functions\expect( 'is_plugin_active_for_network' )->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'current_user_can' )->once()->with( 'manage_network_plugins' )->andReturn( true );
+ Functions\expect( 'is_network_admin' )->andReturn( true );
+ Functions\expect( 'network_admin_url' )->once()->with( 'plugins.php' )->andReturn( 'https://example.com/wp-admin/network/plugins.php' );
+ Functions\expect( 'add_query_arg' )->once()->andReturnUsing(
+ function ( array $args, string $url ): string {
+ return $url . '?' . http_build_query( $args );
+ }
+ );
+ Functions\expect( 'wp_nonce_url' )->once()->andReturnFirstArg();
+ Functions\when( 'esc_html__' )->returnArg();
+ Functions\when( 'esc_url' )->returnArg();
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'notice-warning', $output );
+ $this->assertStringContainsString( 'networkwide=1', $output );
+ $this->assertStringContainsString( 'network/', $output );
+ }
+
+ /**
+ * Test that legacy_plugin_notice outputs nothing for network-activated plugin on site admin.
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_no_output_network_plugin_on_site_admin(): void {
+ Functions\expect( 'is_multisite' )->andReturn( true );
+ Functions\expect( 'is_plugin_active_for_network' )->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'current_user_can' )->once()->with( 'manage_network_plugins' )->andReturn( true );
+ Functions\expect( 'is_network_admin' )->andReturn( false );
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
+
+ /**
+ * Test that legacy_plugin_notice outputs nothing for site-activated plugin on network admin.
+ *
+ * @return void
+ */
+ public function test_legacy_plugin_notice_no_output_site_plugin_on_network_admin(): void {
+ Functions\expect( 'is_multisite' )->andReturn( true );
+ Functions\expect( 'is_plugin_active_for_network' )->with( 'carousel-kit/carousel-kit.php' )->andReturn( false );
+ Functions\expect( 'is_plugin_active' )->once()->with( 'carousel-kit/carousel-kit.php' )->andReturn( true );
+ Functions\expect( 'current_user_can' )->once()->with( 'activate_plugins' )->andReturn( true );
+ Functions\expect( 'is_network_admin' )->andReturn( true );
+
+ $instance = $this->getPluginInstance();
+
+ ob_start();
+ $this->invokeMethod( $instance, 'legacy_plugin_notice' );
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output );
+ }
}