From fd034ff0550e1d326b97a9252ba30badae2dc8c6 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 29 Oct 2026 20:31:39 +0800 Subject: [PATCH] fix(widgets): align tabbed post layouts with reference --- assets/widgets/frontend.css | 75 ++- assets/widgets/frontend.js | 51 +- inc/helpers/posts-renderer/config.php | 3 +- inc/helpers/posts-renderer/markup.php | 4 +- inc/helpers/posts-renderer/post-data.php | 3 +- src/WidgetSystem/FrontendRenderer.php | 18 +- src/WidgetSystem/PostRenderer.php | 51 +- .../Renderers/ContentRenderer.php | 9 +- ...ab-posts-reference-alignment-2026-10-29.md | 33 ++ tests/widget-system/a11y-contract.test.cjs | 4 +- .../page-display-contract.test.cjs | 5 +- .../widget-system/pay-card-contract.test.cjs | 2 +- .../tab-posts-render-behavior.test.cjs | 174 ++++++ .../tab-posts-render-harness.php | 250 +++++++++ .../widget-system/tab-posts-runtime.test.cjs | 530 ++++++++++++++++++ .../tab-posts-style-contract.test.cjs | 81 +++ 16 files changed, 1256 insertions(+), 37 deletions(-) create mode 100644 tests/tab-posts-reference-alignment-2026-10-29.md create mode 100644 tests/widget-system/tab-posts-render-behavior.test.cjs create mode 100644 tests/widget-system/tab-posts-render-harness.php create mode 100644 tests/widget-system/tab-posts-runtime.test.cjs create mode 100644 tests/widget-system/tab-posts-style-contract.test.cjs diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css index 3577b351..2329bb56 100644 --- a/assets/widgets/frontend.css +++ b/assets/widgets/frontend.css @@ -67,8 +67,10 @@ .zib-widget-wrap { padding:var(--aksrt-widget-space-top,0) var(--aksrt-widget-space-right,0) var(--aksrt-widget-space-bottom,0) var(--aksrt-widget-space-left,0); background-color:var(--aksrt-widget-bg-light,transparent); background-image:var(--aksrt-widget-bg-image-light,none); background-position:center; background-size:cover; } .dark-theme .zib-widget-wrap, [data-theme="dark"] .zib-widget-wrap { background-color:var(--aksrt-widget-bg-dark,var(--aksrt-widget-bg-light,transparent)); background-image:var(--aksrt-widget-bg-image-dark,var(--aksrt-widget-bg-image-light,none)); } .zib-widget-wrap .widget-container { position:relative; } -.aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline:3px solid var(--focus-color,#ff5473) !important; outline-offset:3px; } -.dark-theme .aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible, [data-theme="dark"] .aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color:#ffd166 !important; } +.aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible, +.aksrt-tab-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline:3px solid var(--focus-color,#ff5473) !important; outline-offset:3px; } +.dark-theme :is(.aksrt-user-widget-wrap, .aksrt-tab-posts-wrap) :where(a, button, input, select, textarea, [tabindex]):focus-visible, +[data-theme="dark"] :is(.aksrt-user-widget-wrap, .aksrt-tab-posts-wrap) :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color:#ffd166 !important; } .user-card.zib-widget { padding:0; overflow:hidden; } .user-card.widget .user-info > .text-center > p:not(.muted-color) { white-space:nowrap; } @@ -155,7 +157,76 @@ .widget-tab-post .tab-content > .tab-pane { display: none; } .widget-tab-post .tab-content > .tab-pane.active { display: block; } .widget-tab-post, .term-aggregation { line-height:1.42857143; } +/* Public typography overrides Bootstrap headings. Match the reference widget + * without changing article headings elsewhere on the page. */ +.aksrt-tab-posts-wrap { font-size:14px; line-height:1.42857143; color:var(--main-color,#4e5358); } +.widget-tab-post .item-heading { font-weight:500; color:inherit; } +.widget-tab-post .item-heading.font-bold { font-weight:700; } +.widget-tab-post .posts-mini { vertical-align:baseline; } +.widget-tab-post .posts-mini-con { flex:auto; } .widget-tab-post .post_ajax_loader { min-height: 40px; } +/* Keep the reference's compact pagination after public pagination.css. */ +.widget-tab-post .pagenav { + display:block; + padding:20px 0; + line-height:1.4; + text-align:center; +} +.widget-tab-post .pagenav .page-numbers { + display:inline-block; + min-width:31px; + min-height:0; + margin:3px; + padding:6px 9px 5px; + border:0; + border-radius:3px; + background:var(--main-border-color); + color:var(--muted-color); + font-size:inherit; + line-height:inherit; + font-weight:400; +} +.widget-tab-post .pagenav a.page-numbers:hover { background:var(--main-border-color); color:var(--muted-color); opacity:.7; } +.widget-tab-post .pagenav .page-numbers > span { + display:inline; + min-width:0; + min-height:0; + margin:0; + padding:0; + border:0; + border-radius:0; + background:none; + color:inherit; + font-size:inherit; + line-height:inherit; +} +.widget-tab-post .pagenav .page-numbers > .ml6 { margin-left:6px; } +.widget-tab-post .pagenav .page-numbers > .mr6 { margin-right:6px; } +.widget-tab-post .pagenav .prev i, +.widget-tab-post .pagenav .next i { font-size:1.2em; } +.widget-tab-post .pagenav .pag-jump { display:inline-flex; position:relative; width:auto; align-items:center; justify-content:normal; gap:0; } +.widget-tab-post .pagenav .pag-jump .jump-input { + position:absolute; + inset:0; + width:100%; + min-width:0; + height:100%; + padding:5px 22px 5px 5px; + border-radius:3px; + opacity:0; + transition:.2s; +} +.widget-tab-post .pagenav .jump-input:focus, +.widget-tab-post .pagenav .pag-jump:hover .jump-input { opacity:1; } +.sidebar .widget-tab-post .pagenav { padding:10px 5px; } +.sidebar .widget-tab-post .theme-pagination { margin:10px; } +.sidebar .widget-tab-post .pagenav .page-numbers { min-width:28px; margin:2px; font-size:12px; } +@media (max-width:640px) { + .widget-tab-post .pagenav a.page-numbers { display:none; } + .widget-tab-post .pagenav a.next, + .widget-tab-post .pagenav a.prev { display:inline-block; } + .widget-tab-post .pagenav .page-numbers { min-width:28px; margin:2px; font-size:12px; } +} /* Widget pagination mirrors the reference .ajax-next rules while staying on the REST client. */ .theme-pagination { margin: 20px 0; } .theme-pagination .next-page a { border-radius: 30px; padding: 5px 15px; color: var(--muted-color, #8a8c98); opacity: .6; cursor: pointer; } diff --git a/assets/widgets/frontend.js b/assets/widgets/frontend.js index 9a7be9b3..bc8730cb 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -79,9 +79,9 @@ if (window.AksrtYiyan) window.AksrtYiyan.init(figure); } - function errorMarkup() { + function errorMarkup(page, append) { var i18n = config.i18n || {}; - return ''; + return ''; } function itemsUrl(widget, instance, page, segment) { @@ -142,6 +142,7 @@ var instance = owner.dataset.instance || container.dataset.instance; var segment = container.dataset.segment || owner.dataset.segment || ''; if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return; + container.querySelectorAll(':scope > .aksrt-widget-request-error').forEach(function (error) { error.remove(); }); setBusy(container, true); anchorLoader(container); return fetch(itemsUrl(widget, instance, page, segment), { credentials: 'same-origin', headers: { Accept: 'application/json' } }) @@ -173,13 +174,14 @@ container.dataset.loaded = '1'; anchorLoader(container); scan(container); + if (window.jQuery) window.jQuery(document).trigger('auto_fun'); }) .catch(function () { var loader = loaderOf(container); if (loader && loader.parentNode === container) { - loader.insertAdjacentHTML('beforebegin', errorMarkup()); + loader.insertAdjacentHTML('beforebegin', errorMarkup(page, append)); } else { - container.insertAdjacentHTML('afterbegin', errorMarkup()); + container.insertAdjacentHTML('afterbegin', errorMarkup(page, append)); } }) .finally(function () { setBusy(container, false); }); @@ -187,12 +189,40 @@ function initItems(container) { if (!once(container, 'Items')) return; + function jumpTo(input) { + var page = Math.min(Number(input.max), Math.floor(Number(input.value))); + if (!Number.isFinite(page) || page < 1 || page === Number(container.dataset.page || 1)) { + input.focus(); + return; + } + loadItems(container, page, false); + } + container.addEventListener('input', function (event) { + if (!event.target.matches('[data-widget-jump]')) return; + event.stopPropagation(); + var page = Math.floor(Number(event.target.value)); + event.target.value = page > 0 ? String(Math.min(Number(event.target.max), page)) : ''; + }); + container.addEventListener('keydown', function (event) { + if (event.key !== 'Enter' || !event.target.matches('[data-widget-jump]')) return; + event.preventDefault(); + event.stopPropagation(); + jumpTo(event.target); + }); container.addEventListener('click', function (event) { + var jump = event.target.closest('[data-widget-jump-go]'); + if (jump) { + event.preventDefault(); + event.stopPropagation(); + jumpTo(jump.parentElement.querySelector('[data-widget-jump]')); + return; + } var retry = event.target.closest('[data-widget-retry]'); var trigger = event.target.closest('[data-next-page]'); var pageLink = event.target.closest('.page-numbers[data-widget-page]'); if (retry) { - loadItems(container, Number(container.dataset.page || 1), false); + event.preventDefault(); + loadItems(container, Number(retry.dataset.retryPage || 1), retry.dataset.retryAppend === '1'); return; } if (trigger) { @@ -217,6 +247,12 @@ var button = event.target.closest('[role="tab"][data-segment]'); if (!button) return; event.preventDefault(); + var nav = button.closest('[role="tablist"]'); + if (nav && nav.scrollWidth > nav.clientWidth) { + var tabBounds = button.getBoundingClientRect(); + var navBounds = nav.getBoundingClientRect(); + nav.scrollTo({ left: nav.scrollLeft + tabBounds.left - navBounds.left + tabBounds.width / 2 - nav.clientWidth / 2, behavior: reducedMotion ? 'auto' : 'smooth' }); + } tabs.querySelectorAll('[role="tab"]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); tab.setAttribute('tabindex', tab === button ? '0' : '-1'); @@ -227,10 +263,7 @@ item.classList.toggle('active', active); item.classList.toggle('in', active); }); var panel = tabs.querySelector('[data-widget-items][data-segment="' + button.dataset.segment + '"]'); - if (!panel) panel = tabs.querySelector('[data-widget-items]'); if (!panel) return; - panel.dataset.segment = button.dataset.segment; - panel.setAttribute('aria-labelledby', button.id); if (panel.dataset.loaded !== '1') loadItems(panel, 1, false); }); } @@ -311,7 +344,7 @@ scope.querySelectorAll('.aksrt-widget-video[data-video-url]').forEach(initVideo); scope.querySelectorAll('.aksrt-widget-quote').forEach(initQuote); scope.querySelectorAll('[data-widget-items]').forEach(initItems); - scope.querySelectorAll('.aksrt-widget-tabs, .widget-tab-post').forEach(initTabs); + scope.querySelectorAll('.widget-tab-post').forEach(initTabs); scope.querySelectorAll('.aksrt-faq').forEach(initFaq); scope.querySelectorAll('.aksrt-feature-intro').forEach(initFeatures); scope.querySelectorAll('[data-count-up]').forEach(initCount); diff --git a/inc/helpers/posts-renderer/config.php b/inc/helpers/posts-renderer/config.php index fcd76c3e..816522e9 100644 --- a/inc/helpers/posts-renderer/config.php +++ b/inc/helpers/posts-renderer/config.php @@ -38,6 +38,7 @@ if (!function_exists('aksrt_renderer_config')) { * @return array */ function aksrt_renderer_config(array $args = []): array { + $has_no_margin = array_key_exists('no_margin', $args); $args = wp_parse_args($args, aksrt_renderer_defaults()); $list = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings() @@ -74,7 +75,7 @@ if (!function_exists('aksrt_renderer_config')) { $args['list_type'] = $list_type; $args['style'] = $style; $args['img_position'] = sanitize_key((string) ($list_option['img_position'] ?? 'left')) === 'right' ? 'right' : 'left'; - $args['no_margin'] = !empty($args['no_margin']) || (($list['list_show_type'] ?? 'separate') === 'no_margin'); + $args['no_margin'] = $has_no_margin ? !empty($args['no_margin']) : (($list['list_show_type'] ?? 'separate') === 'no_margin'); $args['no_author'] = !empty($args['no_author']) || !empty($args['is_author']); $args['target_blank'] = !empty($list['target_blank']); $args['heading_bold'] = !empty($list['item_heading_bold']); diff --git a/inc/helpers/posts-renderer/markup.php b/inc/helpers/posts-renderer/markup.php index bab3d05d..f815b250 100644 --- a/inc/helpers/posts-renderer/markup.php +++ b/inc/helpers/posts-renderer/markup.php @@ -93,7 +93,7 @@ if (!function_exists('aksrt_renderer_mini')) { $show_time = !empty($cfg['show_time']); if (!empty($cfg['show_author'])) { $author_value = $show_time ? (string) $data['time_ago'] : (string) $data['author_name']; - $meta_left = '' . esc_html($author_value) . ''; + $meta_left = '' . aksrt_get_avatar_box((int) $data['author_id'], 'avatar-mini') . '' . esc_html($author_value) . ''; } else { $meta_left = $show_time ? '' . esc_html((string) $data['time_ago']) . '' : ''; } @@ -186,6 +186,8 @@ if (!function_exists('aksrt_render_post_list')) { if (!function_exists('aksrt_render_mini_list')) { function aksrt_render_mini_list(WP_Query $query, array $args = [], bool $echo = true): string { $cfg = aksrt_renderer_config($args); + // The reference mini rows follow the site's list ordering time source. + $cfg['time_source'] = $cfg['list_orderby'] === 'modified' ? 'modified' : 'date'; $html = ''; $number = max(0, (int) ($args['number_offset'] ?? 0)); while ($query->have_posts()) { diff --git a/inc/helpers/posts-renderer/post-data.php b/inc/helpers/posts-renderer/post-data.php index 11443e7a..0a0a7e6d 100644 --- a/inc/helpers/posts-renderer/post-data.php +++ b/inc/helpers/posts-renderer/post-data.php @@ -87,7 +87,6 @@ if (!function_exists('aksrt_renderer_collect_post_data')) { 'tags' => $tags, 'author_id' => $author_id, 'author_name' => $author instanceof WP_User ? $author->display_name : '', - 'avatar_url' => get_avatar_url($author_id, ['size' => 40]), 'time_title' => $time_value, 'time_ago' => human_time_diff($time_timestamp, current_time('timestamp')) . __('前', 'aksrt'), 'views' => (int) aksrt_get_post_views($post_id), @@ -256,7 +255,7 @@ if (!function_exists('aksrt_renderer_meta')) { $time = $show_time ? '' . esc_html((string) $data['time_ago']) . '' : ''; - $avatar = ''; + $avatar = aksrt_get_avatar_box((int) $data['author_id'], 'avatar-mini'); $meta_left = '' . $avatar . $name . $time . ''; } elseif ($show_time) { $meta_left = '' . esc_html((string) $data['time_ago']) . ''; diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php index 598661e1..9094ff3d 100644 --- a/src/WidgetSystem/FrontendRenderer.php +++ b/src/WidgetSystem/FrontendRenderer.php @@ -23,12 +23,12 @@ final class FrontendRenderer { } AssetManager::enqueue_for($definition); - if (in_array($definition->slug(), ['user_card', 'author_card'], true)) { - self::render_user_widget($definition, $instance, $widget, $content); + if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts'], true)) { + self::render_reference_widget($definition, $instance, $widget, $content); return; } if (in_array($definition->slug(), [ - 'post_pay', 'tab_posts', 'posts', 'oneline_posts', 'hot_posts', + 'post_pay', 'posts', 'oneline_posts', 'hot_posts', 'slider', 'slider_media_combo', 'icon_cards', 'icon_cover_cards', 'buttons', 'notice', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'partners', 'recent_comments', 'posts_toc', @@ -85,8 +85,11 @@ final class FrontendRenderer { echo (string) ($args['after_widget'] ?? ''); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } - private static function render_user_widget(WidgetDefinition $definition, array $instance, SchemaWidget $widget, string $content): void { - $classes = ['zib-widget-wrap', 'aksrt-user-widget-wrap', 'aksrt-' . str_replace('_', '-', $definition->slug()) . '-wrap']; + private static function render_reference_widget(WidgetDefinition $definition, array $instance, SchemaWidget $widget, string $content): void { + $classes = ['zib-widget-wrap', 'aksrt-' . str_replace('_', '-', $definition->slug()) . '-wrap']; + if (in_array($definition->slug(), ['user_card', 'author_card'], true)) { + $classes[] = 'aksrt-user-widget-wrap'; + } $device_class = WidgetVisibility::device_class($instance); if ($device_class !== '') { $classes[] = $device_class; @@ -97,11 +100,10 @@ final class FrontendRenderer { $content_classes = ['widget-content']; if (!empty($instance['animation_in'])) { - $content_classes[] = 'aksrt-widget--animate'; - $content_classes[] = 'aksrt-widget--animate-' . sanitize_html_class((string) $instance['animation_in']); + $content_classes[] = 'obs-animate ani-' . sanitize_html_class((string) $instance['animation_in']); } if (!empty($instance['animation_repeat'])) { - $content_classes[] = 'aksrt-widget--animate-repeat'; + $content_classes[] = 'obs-animate-repeat'; } $style = self::style($instance); diff --git a/src/WidgetSystem/PostRenderer.php b/src/WidgetSystem/PostRenderer.php index 297ec2df..d0a92551 100644 --- a/src/WidgetSystem/PostRenderer.php +++ b/src/WidgetSystem/PostRenderer.php @@ -8,6 +8,13 @@ defined('ABSPATH') || exit; * Shared list/card/mini markup and pagination payloads (reference parity). */ final class PostRenderer { + /** Keep the shell, skeleton and every AJAX page on the widget's selected layout. */ + public static function style(array $instance, string $slug): string { + $default = $slug === 'tab_posts' ? 'mini' : ($slug === 'oneline_posts' ? 'card' : 'list'); + $style = (string) ($instance['style'] ?? $default); + return in_array($style, ['list', 'card', 'mini'], true) ? $style : $default; + } + public static function payload(array $instance, string $slug, int $page = 1, string $segment = ''): array { $segment_config = $slug === 'tab_posts' ? PostQuery::segment($instance, $segment) : []; $query = PostQuery::build($instance, $page, $segment_config); @@ -28,18 +35,24 @@ final class PostRenderer { if (!$query->have_posts()) { return function_exists('aksrt_widget_ajax_null') ? aksrt_widget_ajax_null() : RendererHelpers::empty_state(__('暂无内容', 'aksrt')); } - $style = (string) ($instance['style'] ?? 'card'); - $mode = in_array($style, ['list', 'card', 'mini'], true) ? $style : 'card'; + $mode = self::style($instance, $slug); $mini_opt = (array) ($instance['mini_opt'] ?? []); $config = [ 'type' => $mode === 'card' ? 'card' : 'list', + // Reference widgets render their own stream, independently of the + // surrounding homepage/archive's joined rows and author context. + 'no_margin' => false, + 'is_home' => false, + 'is_category' => false, + 'is_tag' => false, + 'is_author' => false, + 'is_search' => false, 'is_mult_thumb' => 'disable', 'is_no_thumb' => 'disable', 'show_number' => in_array('show_number', $mini_opt, true), 'show_thumb' => in_array('show_thumb', $mini_opt, true), 'show_meta' => in_array('show_meta', $mini_opt, true), 'number_offset' => ($page - 1) * max(1, (int) ($instance['count'] ?? 8)), - 'widget_style' => $mode, ]; if ($slug === 'oneline_posts') { return self::oneline($query, $config, $mode); @@ -93,17 +106,41 @@ final class PostRenderer { } if ($mode === 'number' || $mode === 'numbered') { $links = ''; - $start = max(1, min($page - 2, $max_pages - 4)); - $end = min($max_pages, max($start + 4, $page + 2)); - for ($number = $start; $number <= $end; $number++) { + $mobile = wp_is_mobile(); + $mid_size = $mobile ? 0 : 2; + if ($page > 1) { + $links .= self::page_link($page - 1, '' . esc_html__('上一页', 'aksrt') . '', 'prev'); + } + $dots = false; + for ($number = 1; $number <= $max_pages; $number++) { if ($number === $page) { $links .= '' . $number . ''; + $dots = true; continue; } - $links .= '' . $number . ''; + if ($number === 1 || $number === $max_pages || abs($number - $page) <= $mid_size) { + $links .= self::page_link($number, (string) $number); + $dots = true; + } elseif ($dots) { + $links .= '…'; + $dots = false; + } + } + if ($page < $max_pages) { + $links .= self::page_link($page + 1, '' . esc_html__('下一页', 'aksrt') . '', 'next'); + } + if ($max_pages > ($mobile ? 4 : 8)) { + $links .= '' . esc_html__('跳转', 'aksrt') . ''; } return ''; } + if ($page >= $max_pages) { + return ''; + } return ''; } + + private static function page_link(int $page, string $label, string $class = ''): string { + return '' . $label . ''; + } } diff --git a/src/WidgetSystem/Renderers/ContentRenderer.php b/src/WidgetSystem/Renderers/ContentRenderer.php index 2e28a945..f4a3049b 100644 --- a/src/WidgetSystem/Renderers/ContentRenderer.php +++ b/src/WidgetSystem/Renderers/ContentRenderer.php @@ -10,6 +10,9 @@ defined('ABSPATH') || exit; /** Render article discovery and paid-content widgets (reference parity). */ final class ContentRenderer { public static function render(string $slug, array $instance, array $context = []): string { + if (in_array($slug, ['tab_posts', 'posts', 'oneline_posts'], true)) { + $instance['style'] = PostRenderer::style($instance, $slug); + } switch ($slug) { case 'term_cards': return self::term_cards($instance); case 'term_aggregation': return self::term_aggregation($instance); @@ -24,7 +27,7 @@ final class ContentRenderer { private static function posts(array $instance, array $context, string $slug): string { $widget_id = (string) ($context['widget_id'] ?? ''); - $style = in_array(($instance['style'] ?? 'list'), ['list', 'card', 'mini'], true) ? (string) $instance['style'] : 'list'; + $style = $instance['style']; if ($slug === 'oneline_posts') { // Reference parity: the swiper row is fully rendered server-side. $query = \AKSRT\WidgetSystem\PostQuery::build(array_merge($instance, ['paginate' => '']), 1, []); @@ -73,7 +76,7 @@ final class ContentRenderer { } private static function tab_posts(array $instance, array $context): string { - $style = in_array(($instance['style'] ?? 'mini'), ['list', 'card', 'mini'], true) ? (string) $instance['style'] : 'mini'; + $style = $instance['style']; $tabs = array_values((array) ($instance['tabs'] ?? [])); if (!$tabs) { return ''; @@ -88,7 +91,7 @@ final class ContentRenderer { if (!is_array($tab) || trim((string) ($tab['title'] ?? '')) === '') continue; $tab_id = 'aksrt-tab-' . $key . '-' . $visible_index; $selected = $visible_index === 0; - $buttons .= '
  • ' . wp_kses_post((string) $tab['title']) . '
  • '; + $buttons .= '
  • ' . wp_kses_post((string) $tab['title']) . '
  • '; if ($selected) { $body = self::ajaxpager($instance, 'tab_posts', $widget_id, 1, (string) $index); } else { diff --git a/tests/tab-posts-reference-alignment-2026-10-29.md b/tests/tab-posts-reference-alignment-2026-10-29.md new file mode 100644 index 00000000..6d14b105 --- /dev/null +++ b/tests/tab-posts-reference-alignment-2026-10-29.md @@ -0,0 +1,33 @@ +# 多栏目文章新版源码复核 + +参考目录:`C:\Users\Xing\Desktop\zibll`。本次只对照新版 `zib_widget_ui_tab_post`,没有引入旧版模块、兼容分支或配置迁移,也没有进行浏览器截图或真实视觉效果验收。 + +## 参考依据 + +- `inc/widgets/widget-posts.php`:`zib_widget_ui_tab_post()`、`zib_widget_ui_tab_post_ajax()` 及新版字段声明。默认 mini、6 篇文章,支持 list/card/mini,首栏目直出、其他栏目按需加载,各栏目保存自己的内容。 +- `inc/functions/zib-posts-list.php`:`zib_posts_mini_while()`、`zib_posts_mian_list_list()`、`zib_posts_mian_list_card()`。mini 独立输出紧凑行,list 禁用多图/无图变体;文章作者使用带主页链接及角标的头像框。 +- `inc/csf-framework/classes/widget-options.class.php`:统一布局外壳、`widget-container`、模块间距、日夜背景和入场动画。 +- `css/main.css`:mini 两栏/全宽三栏/侧栏与移动端单栏、栏目导航、文章与分页间距;标题沿用 Bootstrap 默认 500 字重,显式加粗配置另行生效。 +- `inc/functions/functions.php`:追加分页的末页边界,以及数字分页的首尾、相邻页、省略号、上一页/下一页和跳转。 + +## 修复内容 + +- 将外壳、骨架和内容的样式选择统一到 `PostRenderer::style()`,缺省多栏目文章始终使用 mini,后续 AJAX 页面沿用相同规则。 +- 新版多栏目文章走统一参考外壳,补回全宽区域的居中限宽容器,并应用 PC/移动端额外间距、日夜背景和动画。 +- 小工具显式关闭页面上下文及无间距布局。修正共享渲染器对显式 `no_margin=false` 的处理,避免首页的无间距设置改写小工具;普通页面未显式指定时仍继承站点设置。 +- 修正公共标题排版对小工具的覆盖,恢复默认标题字重、mini 文本颜色、行高及紧凑行的对齐方式,同时保留显式加粗。 +- 作者信息改用现有统一头像框,恢复主页链接与角标。清理不再消费的裸头像 URL 字段;mini 日期按参考主题使用列表排序的时间来源。 +- 数字分页补回首尾、上一页/下一页、省略号、桌面/移动端不同的窗口及跳页;恢复参考分页尺寸,避免公共分页样式将按钮和跳转框撑大。 +- 追加分页到末页不再生成下一页按钮;请求失败重试保留失败页及追加方式,避免覆盖已有文章。 +- 栏目切换保留已加载内容和页码;溢出导航按实际元素矩形居中,支持键盘切换;AJAX 成功后初始化新文章的图片和公共交互。 +- 移除重复的 Bootstrap Tab 触发属性、失效的旧栏目选择器及未使用的 `widget_style` 配置字段。新版栏目只有一个交互处理入口。 + +## 验证方式 + +- `tab-posts-render-harness.php` 加载实际文章数据收集、配置和 markup 模块,生成有文章的 default/mini/list/card、分页、移动端及完整前端外壳输出。没有使用空查询或假文章渲染器代替真实内容。 +- `tab-posts-render-behavior.test.cjs` 检查上述输出、未归一化参数、页面上下文隔离、分页边界、头像及时间来源。 +- `tab-posts-runtime.test.cjs` 在 Node VM 中运行实际前端脚本,验证栏目缓存、键盘焦点、导航滚动、追加重试、跳页及与旧运行时事件隔离。 +- `tab-posts-style-contract.test.cjs` 检查覆盖公共排版/分页样式所需的局部选择器及桌面、移动端样式契约。 +- 验证范围为源码、渲染输出、脚本行为和构建检查;不将这些检查描述为像素级视觉验收。 + +结果:仓库全部 Node 测试 785/785 通过(包含本次新增 24 项);小工具 PHP 测试 139 条断言通过;修改的 7 个 PHP 文件语法检查、前端 JS 语法检查、CSS 解析、后台 SPA 构建及差异空白检查通过。 diff --git a/tests/widget-system/a11y-contract.test.cjs b/tests/widget-system/a11y-contract.test.cjs index c86e3f01..6239592e 100644 --- a/tests/widget-system/a11y-contract.test.cjs +++ b/tests/widget-system/a11y-contract.test.cjs @@ -14,7 +14,9 @@ const general = read('src/WidgetSystem/Renderers/GeneralRenderer.php'); for (const key of ['ArrowRight', 'ArrowLeft', 'Home', 'End']) assert.match(js, new RegExp(key)); assert.match(js, /setAttribute\('tabindex', tab === button \? '0' : '-1'\)/); -assert.match(js, /setAttribute\('aria-labelledby', button\.id\)/); +// Each pane receives its label in server markup. Switching activates that +// existing aria-controls target instead of relabelling the inner AJAX pager. +assert.match(js, /item\.id === button\.getAttribute\('aria-controls'\)/); assert.match(content, /role="tab"[^;]+aria-controls=/); assert.match(content, /role="tabpanel"[^;]+aria-labelledby=/); assert.match(knowledge, /role="tab"[^;]+aria-controls=/); diff --git a/tests/widget-system/page-display-contract.test.cjs b/tests/widget-system/page-display-contract.test.cjs index 0b12562f..3132d613 100644 --- a/tests/widget-system/page-display-contract.test.cjs +++ b/tests/widget-system/page-display-contract.test.cjs @@ -300,8 +300,9 @@ test('header multifunction search keeps focus and submit alignment route-indepen test('user profile and author widgets render the reference user-card contract', () => { assert.match(frontendRenderer, /in_array\(\$definition->slug\(\), \['user_card', 'author_card'\], true\)/) - assert.match(frontendRenderer, /self::render_user_widget\(\$definition, \$instance, \$widget, \$content\)/) - assert.match(frontendRenderer, /\$classes = \['zib-widget-wrap', 'aksrt-user-widget-wrap'/) + assert.match(frontendRenderer, /self::render_reference_widget\(\$definition, \$instance, \$widget, \$content\)/) + assert.match(frontendRenderer, /\$classes = \['zib-widget-wrap', 'aksrt-'/) + assert.match(frontendRenderer, /\$classes\[\] = 'aksrt-user-widget-wrap'/) assert.match(frontendRenderer, /echo '
    ';/) assert.match(frontendRenderer, /\$content_classes = \['widget-content'\]/) // 登录卡片与 reference zib_get_user_card_box 同构:封面、头像框、名字行、统计徽章、简介、按钮。 diff --git a/tests/widget-system/pay-card-contract.test.cjs b/tests/widget-system/pay-card-contract.test.cjs index 7a43c596..30665e08 100644 --- a/tests/widget-system/pay-card-contract.test.cjs +++ b/tests/widget-system/pay-card-contract.test.cjs @@ -46,7 +46,7 @@ test('paid article and sidebar widget use the Zibll purchase-card DOM', () => { assert.match(pay, /支付金额/); assert.match(pay, /hidden-box show/); assert.match(widget, /'layout' => 'widget'/); - assert.match(shell, /'post_pay', 'tab_posts', 'posts'/); + assert.match(shell, /'post_pay', 'posts', 'oneline_posts'/); assert.doesNotMatch(single, /aksrt-panel pay-box pay-type-download/); assert.doesNotMatch(single, /get_template_part\('templates\/parts\/resource-download-box'\)/); assert.match(single, /aksrt_get_paid_box_html\(\$post\)/); diff --git a/tests/widget-system/tab-posts-render-behavior.test.cjs b/tests/widget-system/tab-posts-render-behavior.test.cjs new file mode 100644 index 00000000..d6352174 --- /dev/null +++ b/tests/widget-system/tab-posts-render-behavior.test.cjs @@ -0,0 +1,174 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +function render() { + const output = execFileSync('php', [path.join(__dirname, 'tab-posts-render-harness.php')], { encoding: 'utf8' }); + return JSON.parse(output).cases; +} + +test('missing and invalid tab style use the reference mini layout and ignore global post-card settings', () => { + const cases = render(); + for (const name of ['default', 'invalid']) { + const current = cases[name]; + assert.equal(current.instance_style, 'mini'); + assert.match(current.tab_markup, /widget-tab-post style-mini posts-mini-lists zib-widget/); + assert.match(current.first, /class="posts-mini ajax-item"/); + assert.doesNotMatch(current.first, /class="mr10"|class="item-meta|class="badg badg-sm/); + assert.doesNotMatch(current.first, /class="posts-item list|class="posts-item card|class="no_margin/); + } +}); + +test('un-normalized missing and invalid style use one shared mini resolution in both renderers', () => { + const cases = render(); + for (const name of ['raw_default', 'raw_invalid']) { + assert.match(cases[name].tab_markup, /widget-tab-post style-mini posts-mini-lists zib-widget/); + assert.match(cases[name].first, /class="posts-mini ajax-item"/); + assert.doesNotMatch(cases[name].first, /class="posts-item list|class="posts-item card/); + } +}); + +test('mini options render thumbnail, rank, author and meta on both pages with continuous numbering', () => { + const current = render().mini_all_options; + assert.equal((current.first.match(/class="posts-mini ajax-item"/g) || []).length, 4); + assert.match(current.first, /class="mr10">
    1<\/span>/); + assert.match(current.first, /class="meta-author flex ac"/); + assert.match(current.first, /class="item-meta muted-2-color flex jsb ac/); + assert.match(current.second, /badg badg-sm mr3 c-green">5<\/span>/); + assert.match(current.second, /真实文章 36/); + assert.doesNotMatch(current.first + current.second, /class="posts-row|class="posts-item list|class="posts-item card/); +}); + +test('mini, list and card meta use the linked avatar box with its badge image contract', () => { + const cases = render(); + const avatarBox = /]*>]*><\/span><\/a>/; + for (const name of ['list', 'card', 'mini_all_options']) { + const current = cases[name]; + assert.match(current.first, avatarBox, 'the shared linked avatar wrapper and badge stay in the article meta'); + assert.doesNotMatch(current.first, / 0, 'the shared avatar renderer receives each visible author'); + for (const call of current.avatar_calls) { + assert.deepEqual(call, { user_id: 5, class: 'avatar-mini', link: true, vip: true }); + } + } +}); + +test('mini publication time follows list_orderby even when the generic list uses modified time', () => { + const cases = render(); + assert.match(cases.mini_time_source.html, /title="2026-01-01 00:00:00" class="icon-circle mln3">发布前/); + assert.match(cases.list.first, /title="2026-02-01 00:00:00" class="icon-circle">修改前/); +}); + +test('list tabs use the reference list item structure without inheriting archive context', () => { + const current = render().list; + assert.match(current.tab_markup, /widget-tab-post style-list index-tab/); + assert.match(current.first, / { + const current = render().card; + assert.match(current.tab_markup, /widget-tab-post style-card index-tab/); + assert.match(current.first, / { + const markup = render().mini.tab_markup; + assert.equal((markup.match(/role="tab"/g) || []).length, 2); + assert.match(markup, /aria-selected="true" tabindex="0" data-segment="0"/); + assert.match(markup, /aria-selected="false" tabindex="-1" data-segment="2"/); + assert.doesNotMatch(markup, /data-segment="1"/); + assert.match(markup, /class="tab-pane fade active in"/); + assert.match(markup, /data-widget-items data-widget="tab_posts"[^>]*data-segment="2"/); +}); + +test('pagination follows the reference boundaries, ellipsis, jump control, and final-page rules', () => { + const cases = render(); + const first = cases.list.pagination; + const last = cases.list.last_pagination; + assert.match(first, /class="page-numbers current">1<\/span>/); + assert.match(first, /data-widget-page="2"/); + assert.match(first, /class="page-numbers dots">…<\/span>/); + assert.match(first, /data-widget-page="9"/); + assert.match(first, /max="9" current="1"[^>]*data-widget-jump/); + assert.doesNotMatch(first, /class="next-page page-numbers prev"/); + assert.match(last, /class="next-page page-numbers prev"[^>]*data-widget-page="8"/); + assert.match(last, /class="page-numbers current">9<\/span>/); + assert.doesNotMatch(last, /class="next-page page-numbers next"/); + assert.match(last, /max="9" current="9"[^>]*data-widget-jump/); + assert.match(cases.ajax_pagination.first, /data-next-page="2"/); + assert.equal(cases.ajax_pagination.last, ''); +}); + +test('mobile pagination uses no middle neighbors and retains the jump control through the final page', () => { + const current = render().mobile_pagination; + const middleJump = current.middle.match(/]*data-widget-jump[^>]*>/)?.[0] || ''; + assert.match(middleJump, /type="number"/); + assert.match(middleJump, /inputmode="numeric"/); + assert.match(current.middle, /class="page-numbers current">5<\/span>/); + assert.match(current.middle, /data-widget-page="1"/); + assert.match(current.middle, /data-widget-page="9"/); + assert.doesNotMatch(current.middle, /class="next-page page-numbers"[^>]*data-widget-page="4"/); + assert.doesNotMatch(current.middle, /class="next-page page-numbers"[^>]*data-widget-page="6"/); + assert.equal((current.middle.match(/class="page-numbers dots"/g) || []).length, 2); + assert.match(current.last, /class="page-numbers current">9<\/span>/); + assert.match(current.last, /data-widget-page="8"/); + assert.doesNotMatch(current.last, /class="next-page page-numbers next"/); + const lastJump = current.last.match(/]*data-widget-jump[^>]*>/)?.[0] || ''; + assert.match(lastJump, /max="9"/); + assert.match(lastJump, /current="9"/); + assert.match(lastJump, /type="number"/); +}); + +test('list margin defaults inherit the site setting while an explicit widget false stays false', () => { + const current = render().context_margins; + assert.equal(current.implicit, true); + assert.equal(current.explicit_false, false); +}); + +test('multi-column frontend output uses the reference widget wrapper and carries layout variables', () => { + const wrapper = render().frontend_wrapper; + assert.match(wrapper, /class="zib-widget-wrap aksrt-tab-posts-wrap"/); + assert.match(wrapper, /data-aksrt-widget="tab_posts" data-widget-id="aksrt_widget_tab_posts-4"/); + assert.match(wrapper, /--aksrt-widget-space-top:8px/); + assert.match(wrapper, /--aksrt-widget-space-m-bottom:10px/); + assert.match(wrapper, /--aksrt-widget-bg-light:#ffffff/); + assert.match(wrapper, /--aksrt-widget-bg-dark:#111111/); + assert.match(wrapper, /widget-container/); + assert.match(wrapper, /class="widget-content"/); + assert.match(wrapper, /class="widget-tab-post style-mini/); + assert.doesNotMatch(wrapper, /aksrt-widget__inner container|