' . esc_html( $title ) . '' . $type_badge . '
';
if ( Visualizer_Module::is_pro() && $with_filter ) {
echo '
';
echo '
';
@@ -426,14 +480,14 @@ private function _renderChartBox( $placeholder_id, $chart_id, $with_filter = fal
}
echo '
';
}
+ /**
+ * Returns true when the library should render in list (no-preview) mode.
+ *
+ * Priority: ?view= URL param (saves to user meta) → saved user meta → grid default.
+ *
+ * No nonce needed: this is a bookmarkable UI preference URL. A nonce would expire
+ * and break saved/shared links for zero real security gain — the value is allowlisted
+ * to 'list'|'grid' before any write happens.
+ */
+ private function _isListView(): bool {
+ if ( null !== $this->_list_view_cached ) {
+ return $this->_list_view_cached;
+ }
+ if ( isset( $_GET['view'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $view = sanitize_text_field( wp_unslash( $_GET['view'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( in_array( $view, array( 'list', 'grid' ), true ) ) {
+ update_user_meta( get_current_user_id(), 'visualizer_library_view', $view );
+ }
+ $this->_list_view_cached = ( 'list' === $view );
+ } else {
+ $saved = get_user_meta( get_current_user_id(), 'visualizer_library_view', true );
+ $this->_list_view_cached = ( 'list' === $saved );
+ }
+ return $this->_list_view_cached;
+ }
+
+ /**
+ * Returns the HTML for the grid/list view toggle links.
+ */
+ private function _getViewToggleHTML(): string {
+ $is_list = $this->_isListView();
+ $grid_url = esc_url( add_query_arg( 'view', 'grid' ) );
+ $list_url = esc_url( add_query_arg( 'view', 'list' ) );
+ return '
'
+ . '
';
+ }
+
/**
* Render 2-col sidebar
*/
private function _renderSidebar() {
if ( ! Visualizer_Module::is_pro() ) {
- echo '
';
- echo '
';
- echo '
';
- echo '';
- echo '
';
- echo '
';
- echo '
';
}
}
}
diff --git a/css/library.css b/css/library.css
index b11e71db..20d70016 100644
--- a/css/library.css
+++ b/css/library.css
@@ -627,3 +627,295 @@ div#visualizer-types ul, div#visualizer-types form p {
text-align: inherit;
text-decoration: none;
}
+
+/* ── View toggle buttons ── */
+span.viz-view-toggle-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 0;
+ margin-right: 8px;
+ vertical-align: middle;
+ border: 1px solid #c3c4c7;
+ border-radius: 4px;
+ padding: 1px;
+ background: #f6f7f7;
+}
+
+.viz-view-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ border-radius: 3px;
+ color: #646970;
+ line-height: 1;
+ text-decoration: none;
+}
+
+.viz-view-toggle.active {
+ background: #007CBA;
+ color: #fff;
+}
+
+.viz-view-toggle:hover:not(.active) {
+ background: #e0e0e0;
+ color: #1d2327;
+}
+
+.viz-view-toggle:focus {
+ outline: 2px solid #007CBA;
+ outline-offset: 0;
+ box-shadow: none;
+}
+
+/* ── Edit button — more visible ── */
+.visualizer-action-group:not(.visualizer-nochart) .visualizer-chart-action.visualizer-chart-edit {
+ background-color: #007CBA;
+ color: #fff;
+ border-radius: 4px;
+}
+
+.visualizer-action-group:not(.visualizer-nochart) .visualizer-chart-action.visualizer-chart-edit:hover,
+.visualizer-action-group:not(.visualizer-nochart) .visualizer-chart-action.visualizer-chart-edit:focus {
+ background-color: #0069a6 !important;
+ color: #fff !important;
+}
+
+/* ── Compact upsell banner ── */
+#visualizer-library .items--upsell {
+ width: 100%;
+}
+
+.viz-upsell-banner {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: #fff;
+ border-left: 4px solid #007CBA;
+ padding: 12px 16px;
+ margin-bottom: 10px;
+}
+
+.viz-upsell-banner__icon {
+ color: #007CBA;
+ font-size: 20px;
+ flex-shrink: 0;
+}
+
+.viz-upsell-banner__text {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.viz-upsell-banner__text strong {
+ font-size: 13px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.viz-upsell-banner__text span {
+ font-size: 12px;
+ color: #50575e;
+}
+
+.viz-upsell-banner__actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+/* ── Chart type badge (list view only) ── */
+.viz-chart-type-badge {
+ display: none;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: capitalize;
+ background: #e8f0f8;
+ color: #007CBA;
+ border: 1px solid #c3daf9;
+ border-radius: 10px;
+ padding: 2px 8px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* ── List view: WP-style table ── */
+#visualizer-library.view-list {
+ display: block;
+ margin: 20px 0;
+}
+
+.viz-charts-table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.viz-charts-table thead th {
+ padding: 10px 12px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #1d2327;
+ text-align: left;
+ background: #f6f7f7;
+ border-bottom: 2px solid #c3c4c7;
+ white-space: nowrap;
+}
+
+.viz-charts-table tbody tr {
+ background: #fff;
+ border-bottom: 1px solid #f0f0f1;
+ transition: background 0.1s;
+}
+
+.viz-charts-table tbody tr:hover {
+ background: #f6f7f7;
+}
+
+.viz-charts-table td {
+ padding: 10px 12px;
+ vertical-align: middle;
+ font-size: 13px;
+ color: #3c434a;
+}
+
+/* Column widths */
+.viz-charts-table .col-id {
+ width: 50px;
+ font-weight: 600;
+ color: #787c82;
+}
+
+.viz-charts-table .col-title {
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.viz-charts-table .col-type {
+ width: 110px;
+}
+
+.viz-charts-table .col-shortcode {
+ width: 260px;
+}
+
+.viz-charts-table .col-actions {
+ width: 1%;
+ white-space: nowrap;
+}
+
+/* Type badge in table */
+.viz-charts-table .viz-chart-type-badge {
+ display: inline-block;
+}
+
+/* Shortcode cell */
+.viz-shortcode-display {
+ display: inline-block;
+ font-size: 11px;
+ background: #f6f7f7;
+ border: 1px solid #ddd;
+ border-radius: 3px;
+ padding: 3px 7px;
+ color: #50575e;
+ white-space: nowrap;
+ max-width: 260px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: middle;
+ font-family: Consolas, Monaco, monospace;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.viz-shortcode-display:hover {
+ background: #e8f0f8;
+ border-color: #bfdbfe;
+ color: #1d2327;
+}
+
+.viz-shortcode-display.viz-shortcode-copied {
+ background: #dcfce7;
+ border-color: #86efac;
+ color: #15803d;
+}
+
+/* Action buttons in table: colored bordered icon buttons */
+.viz-charts-table .visualizer-action-group {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 4px;
+ justify-content: flex-end;
+}
+
+.viz-charts-table .visualizer-chart-action {
+ width: 32px;
+ height: 32px;
+ border: 1px solid #c3c4c7;
+ border-radius: 4px;
+ background: #fff;
+ color: #646970;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.viz-charts-table .visualizer-chart-action:hover {
+ background: #f6f7f7;
+}
+
+.viz-charts-table .visualizer-chart-action.visualizer-chart-delete {
+ border-color: #fca5a5;
+ color: #dc2626;
+ margin-right: 0;
+}
+
+.viz-charts-table .visualizer-chart-action.visualizer-chart-delete:hover {
+ background: #fef2f2;
+}
+
+.viz-charts-table .visualizer-chart-action.visualizer-chart-shortcode {
+ color: #2563eb;
+ border-color: #bfdbfe;
+}
+
+.viz-charts-table .visualizer-chart-action.visualizer-chart-shortcode:hover {
+ background: #eff6ff;
+}
+
+.viz-charts-table .visualizer-chart-action.visualizer-chart-clone,
+.viz-charts-table .visualizer-chart-action.visualizer-chart-export {
+ color: #64748b;
+ border-color: #cbd5e1;
+}
+
+.viz-charts-table .visualizer-chart-action.visualizer-chart-clone:hover,
+.viz-charts-table .visualizer-chart-action.visualizer-chart-export:hover {
+ background: #f8fafc;
+}
+
+/* Edit button stays blue in table */
+.viz-charts-table .visualizer-action-group .visualizer-chart-action.visualizer-chart-edit {
+ background-color: #007CBA;
+ border-color: #007CBA;
+ color: #fff;
+ border-radius: 4px;
+}
+
+.viz-charts-table .visualizer-action-group .visualizer-chart-action.visualizer-chart-edit:hover {
+ background-color: #0069a6 !important;
+ border-color: #0069a6 !important;
+ color: #fff !important;
+}
+
+/* Upsell banner after the table */
+#visualizer-library.view-list .items--upsell {
+ margin-top: 12px;
+}
diff --git a/js/library.js b/js/library.js
index d0efddd1..046ed7b5 100644
--- a/js/library.js
+++ b/js/library.js
@@ -46,6 +46,9 @@ function createPopupProBlocker( $ , e ) {
var resizeTimeout;
$.fn.adjust = function () {
+ if ( $( '#visualizer-library' ).hasClass( 'view-list' ) ) {
+ return this;
+ }
return $(this).each(function () {
var width = $('#visualizer-library').width(),
margin = width * 0.02;
@@ -85,6 +88,26 @@ function createPopupProBlocker( $ , e ) {
$(this).parent('form').submit();
});
+ // Copy shortcode when clicking the code display in list view.
+ $( document ).on( 'click', '.viz-shortcode-display', function () {
+ var text = $( this ).text();
+ var el = this;
+ if ( navigator.clipboard ) {
+ navigator.clipboard.writeText( text );
+ } else {
+ var ta = document.createElement( 'textarea' );
+ ta.value = text;
+ document.body.appendChild( ta );
+ ta.select();
+ document.execCommand( 'copy' );
+ document.body.removeChild( ta );
+ }
+ $( el ).addClass( 'viz-shortcode-copied' );
+ setTimeout( function () {
+ $( el ).removeClass( 'viz-shortcode-copied' );
+ }, 1200 );
+ } );
+
$('.visualizer-chart-shortcode').click(function (event) {
if ( createPopupProBlocker( $, event ) ) {
diff --git a/tests/e2e/specs/library-view.spec.js b/tests/e2e/specs/library-view.spec.js
new file mode 100644
index 00000000..b002d2b2
--- /dev/null
+++ b/tests/e2e/specs/library-view.spec.js
@@ -0,0 +1,146 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+/**
+ * Internal dependencies
+ */
+const { createChartWithAdmin, deleteAllCharts, waitForLibraryToLoad } = require( '../utils/common' );
+
+test.describe( 'Library View Toggle', () => {
+
+ test.beforeEach( async ( { admin, requestUtils } ) => {
+ await deleteAllCharts( requestUtils );
+ // Explicitly start in grid view and reset user meta preference.
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=grid' );
+ } );
+
+ test.afterEach( async ( { admin } ) => {
+ // Reset view preference to grid so other test suites start in a clean state.
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=grid' );
+ } );
+
+ test( 'view toggle buttons are visible', async ( { page } ) => {
+ await expect( page.locator( '.viz-view-toggle-group' ) ).toBeVisible();
+ await expect( page.locator( 'a.viz-view-toggle' ).count() ).resolves.toBe( 2 );
+
+ // Grid toggle is active by default.
+ await expect( page.locator( 'a.viz-view-toggle.active[title="Grid View"]' ) ).toBeVisible();
+ await expect( page.locator( 'a.viz-view-toggle:not(.active)[title="List View"]' ) ).toBeVisible();
+ } );
+
+ test( 'default view is grid', async ( { page } ) => {
+ await expect( page.locator( '#visualizer-library.view-grid' ) ).toBeVisible();
+ await expect( page.locator( '#visualizer-library.view-list' ) ).toHaveCount( 0 );
+ } );
+
+ test( 'clicking list toggle navigates to list view and flips active state', async ( { page } ) => {
+ await page.locator( 'a.viz-view-toggle[title="List View"]' ).click();
+ await page.waitForURL( '**/admin.php**view=list**' );
+
+ await expect( page ).toHaveURL( /view=list/ );
+ // Active state should flip.
+ await expect( page.locator( 'a.viz-view-toggle.active[title="List View"]' ) ).toBeVisible();
+ await expect( page.locator( 'a.viz-view-toggle:not(.active)[title="Grid View"]' ) ).toBeVisible();
+ } );
+
+ test( 'list view table has correct column headers', async ( { admin, page } ) => {
+ await createChartWithAdmin( admin, page );
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=list' );
+ await waitForLibraryToLoad( page );
+
+ const headers = page.locator( 'table.viz-charts-table thead th' );
+ await expect( headers ).toHaveCount( 5 );
+ await expect( headers.nth( 0 ) ).toHaveText( 'ID' );
+ await expect( headers.nth( 1 ) ).toHaveText( 'Title' );
+ await expect( headers.nth( 2 ) ).toHaveText( 'Type' );
+ await expect( headers.nth( 3 ) ).toHaveText( 'Shortcode' );
+ await expect( headers.nth( 4 ) ).toHaveText( 'Actions' );
+ } );
+
+ test( 'list view shows chart data in table row', async ( { admin, page } ) => {
+ const chartId = await createChartWithAdmin( admin, page );
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=list' );
+ await waitForLibraryToLoad( page );
+
+ const row = page.locator( 'tr.viz-list-row' ).filter( { hasText: `#${ chartId }` } );
+ await expect( row ).toBeVisible();
+
+ // Shortcode cell contains the correct chart ID.
+ await expect( row.locator( 'code.viz-shortcode-display' ) ).toContainText( `[visualizer id="${ chartId }"` );
+
+ // All action buttons are present.
+ await expect( row.locator( 'a.visualizer-chart-edit' ) ).toBeVisible();
+ await expect( row.locator( 'a.visualizer-chart-delete' ) ).toBeVisible();
+ await expect( row.locator( 'a.visualizer-chart-clone' ) ).toBeVisible();
+ await expect( row.locator( 'a.visualizer-chart-export' ) ).toBeVisible();
+ await expect( row.locator( 'a.visualizer-chart-shortcode' ) ).toBeVisible();
+ } );
+
+ test( 'list view does not render chart canvas elements', async ( { admin, page } ) => {
+ const chartId = await createChartWithAdmin( admin, page );
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=list' );
+ await waitForLibraryToLoad( page );
+
+ // No canvas/chart preview elements — list view skips rendering them.
+ await expect( page.locator( `#visualizer-${ chartId }` ) ).toHaveCount( 0 );
+ await expect( page.locator( '.visualizer-chart-canvas' ) ).toHaveCount( 0 );
+ } );
+
+ test( 'clicking shortcode display copies shortcode to clipboard', async ( { admin, page } ) => {
+ const chartId = await createChartWithAdmin( admin, page );
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=list' );
+ await waitForLibraryToLoad( page );
+
+ const row = page.locator( 'tr.viz-list-row' ).filter( { hasText: `#${ chartId }` } );
+ await row.locator( 'code.viz-shortcode-display' ).click();
+
+ const clipboard = await page.evaluate( () => navigator.clipboard.readText() );
+ expect( clipboard ).toMatch( new RegExp( `\\[visualizer id="${ chartId }"` ) );
+ } );
+
+ test( 'view preference persists across page visits via user meta', async ( { admin, page } ) => {
+ await createChartWithAdmin( admin, page );
+ // createChartWithAdmin lands on admin.php?page=visualizer — chart exists, grid view (user meta = grid).
+
+ // Switch to list view — saves 'list' to user meta.
+ await page.locator( 'a.viz-view-toggle[title="List View"]' ).click();
+ await page.waitForURL( '**/admin.php**view=list**' );
+ await waitForLibraryToLoad( page );
+
+ // Navigate back without ?view= param — user meta should restore list view directly.
+ await admin.visitAdminPage( 'admin.php?page=visualizer' );
+ await waitForLibraryToLoad( page );
+
+ await expect( page.locator( '#visualizer-library.view-list' ) ).toBeVisible();
+ await expect( page.locator( 'table.viz-charts-table' ) ).toBeVisible();
+ } );
+
+ test( 'switching back to grid view works', async ( { admin, page } ) => {
+ await createChartWithAdmin( admin, page );
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=list' );
+ await waitForLibraryToLoad( page );
+ await expect( page.locator( '#visualizer-library.view-list' ) ).toBeVisible();
+
+ await page.locator( 'a.viz-view-toggle[title="Grid View"]' ).click();
+ await page.waitForURL( '**/admin.php**view=grid**' );
+ await waitForLibraryToLoad( page );
+
+ await expect( page.locator( '#visualizer-library.view-grid' ) ).toBeVisible();
+ await expect( page.locator( 'table.viz-charts-table' ) ).toHaveCount( 0 );
+ } );
+
+ test( 'applying filters preserves the current view', async ( { admin, page } ) => {
+ await createChartWithAdmin( admin, page );
+ await admin.visitAdminPage( 'admin.php?page=visualizer&view=list' );
+ await waitForLibraryToLoad( page );
+
+ // Submit the filter form — the hidden view input should carry list view through.
+ await page.getByRole( 'button', { name: 'Apply Filters' } ).click();
+ await waitForLibraryToLoad( page );
+
+ await expect( page.locator( '#visualizer-library.view-list' ) ).toBeVisible();
+ } );
+
+} );