fix(widgets): align tabbed post layouts with reference

This commit is contained in:
kate522 committed 2026-10-29 20:31:39 +08:00
1 parent 17515cf9c5
commit fd034ff055
16 files changed
+1256 -37

No files matched your search

+73 -2
View File
@@ -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; }
+42 -9
View File
@@ -79,9 +79,9 @@
if (window.AksrtYiyan) window.AksrtYiyan.init(figure);
}
function errorMarkup() {
function errorMarkup(page, append) {
var i18n = config.i18n || {};
return '<div class="aksrt-widget-request-error" role="alert"><span>' + (i18n.error || '加载失败,请重试。') + '</span><button type="button" data-widget-retry>' + (i18n.retry || '重试') + '</button></div>';
return '<div class="aksrt-widget-request-error" role="alert"><span>' + (i18n.error || '加载失败,请重试。') + '</span><button type="button" data-widget-retry data-retry-page="' + page + '" data-retry-append="' + (append ? '1' : '0') + '">' + (i18n.retry || '重试') + '</button></div>';
}
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);
+2 -1
View File
@@ -38,6 +38,7 @@ if (!function_exists('aksrt_renderer_config')) {
* @return array<string,mixed>
*/
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']);
+3 -1
View File
@@ -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 = '<item class="meta-author flex ac"><img class="avatar-mini" src="' . esc_url((string) $data['avatar_url']) . '" alt="" width="20" height="20"><span class="ml6">' . esc_html($author_value) . '</span></item>';
$meta_left = '<item class="meta-author flex ac">' . aksrt_get_avatar_box((int) $data['author_id'], 'avatar-mini') . '<span class="ml6">' . esc_html($author_value) . '</span></item>';
} else {
$meta_left = $show_time ? '<item title="' . esc_attr((string) $data['time_title']) . '" class="icon-circle mln3">' . esc_html((string) $data['time_ago']) . '</item>' : '';
}
@@ -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()) {
+1 -2
View File
@@ -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
? '<span title="' . esc_attr((string) $data['time_title']) . '" class="' . ($is_card ? 'ml6' : 'icon-circle') . '">' . esc_html((string) $data['time_ago']) . '</span>'
: '';
$avatar = '<img class="avatar-mini" src="' . esc_url((string) $data['avatar_url']) . '" alt="" width="20" height="20">';
$avatar = aksrt_get_avatar_box((int) $data['author_id'], 'avatar-mini');
$meta_left = '<item class="meta-author flex ac">' . $avatar . $name . $time . '</item>';
} elseif ($show_time) {
$meta_left = '<item title="' . esc_attr((string) $data['time_title']) . '" class="icon-circle mln3">' . esc_html((string) $data['time_ago']) . '</item>';
+10 -8
View File
@@ -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'] ?? '</section>'); // 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);
+44 -7
View File
@@ -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, '<i class="fa fas fa-angle-left em12" aria-hidden="true"></i><span class="hide-sm ml6">' . esc_html__('上一页', 'aksrt') . '</span>', 'prev');
}
$dots = false;
for ($number = 1; $number <= $max_pages; $number++) {
if ($number === $page) {
$links .= '<span class="page-numbers current">' . $number . '</span>';
$dots = true;
continue;
}
$links .= '<a class="next-page page-numbers" ajaxpager-target=".widget-ajaxpager" ajax-replace="true" data-widget-page="' . $number . '" role="button" tabindex="0" href="#page-' . $number . '">' . $number . '</a>';
if ($number === 1 || $number === $max_pages || abs($number - $page) <= $mid_size) {
$links .= self::page_link($number, (string) $number);
$dots = true;
} elseif ($dots) {
$links .= '<span class="page-numbers dots">&hellip;</span>';
$dots = false;
}
}
if ($page < $max_pages) {
$links .= self::page_link($page + 1, '<span class="hide-sm mr6">' . esc_html__('下一页', 'aksrt') . '</span><i class="fa fas fa-angle-right em12" aria-hidden="true"></i>', 'next');
}
if ($max_pages > ($mobile ? 4 : 8)) {
$links .= '<span class="pag-jump page-numbers"><input autocomplete="off" min="1" max="' . $max_pages . '" current="' . $page . '" type="' . ($mobile ? 'number' : 'text') . '" inputmode="numeric" class="form-control jump-input" data-widget-jump aria-label="' . esc_attr__('跳转页码', 'aksrt') . '"><span class="hi de-sm mr6 jump-text">' . esc_html__('跳转', 'aksrt') . '</span><button type="button" class="jump-icon fa fas fa-angle-double-right em12" data-widget-jump-go aria-label="' . esc_attr__('跳转', 'aksrt') . '"></button></span>';
}
return '<div class="pagenav ajax-pag" role="navigation" aria-label="' . esc_attr__('文章分页', 'aksrt') . '">' . $links . '</div>';
}
if ($page >= $max_pages) {
return '';
}
return '<div class="text-center theme-pagination ajax-pag"><div no-scroll="true" ajaxpager-target=".widget-ajaxpager" class="next-page"><a role="button" tabindex="0" href="#next" data-next-page="' . (int) ($page + 1) . '">' . esc_html__('加载更多', 'aksrt') . '</a></div></div>';
}
private static function page_link(int $page, string $label, string $class = ''): string {
return '<a class="next-page page-numbers' . ($class !== '' ? ' ' . esc_attr($class) : '') . '" ajaxpager-target=".widget-ajaxpager" ajax-replace="true" data-widget-page="' . $page . '" role="button" tabindex="0" href="#page-' . $page . '">' . $label . '</a>';
}
}
@@ -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 .= '<li class="' . ($selected ? 'active' : '') . '"><a' . ($selected ? '' : ' data-ajax') . ' data-toggle="tab" href="#' . esc_attr($panel_id . '-' . $visible_index) . '" role="tab" id="' . esc_attr($tab_id) . '" aria-controls="' . esc_attr($panel_id . '-' . $visible_index) . '" aria-selected="' . ($selected ? 'true' : 'false') . '" tabindex="' . ($selected ? '0' : '-1') . '" data-segment="' . (int) $index . '">' . wp_kses_post((string) $tab['title']) . '</a></li>';
$buttons .= '<li class="' . ($selected ? 'active' : '') . '"><a href="#' . esc_attr($panel_id . '-' . $visible_index) . '" role="tab" id="' . esc_attr($tab_id) . '" aria-controls="' . esc_attr($panel_id . '-' . $visible_index) . '" aria-selected="' . ($selected ? 'true' : 'false') . '" tabindex="' . ($selected ? '0' : '-1') . '" data-segment="' . (int) $index . '">' . wp_kses_post((string) $tab['title']) . '</a></li>';
if ($selected) {
$body = self::ajaxpager($instance, 'tab_posts', $widget_id, 1, (string) $index);
} else {
@@ -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 构建及差异空白检查通过。
+3 -1
View File
@@ -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=/);
@@ -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 '<div class="widget-container"><div>';/)
assert.match(frontendRenderer, /\$content_classes = \['widget-content'\]/)
// 登录卡片与 reference zib_get_user_card_box 同构:封面、头像框、名字行、统计徽章、简介、按钮。
@@ -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\)/);
@@ -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"><div class="item-thumbnail"/);
assert.match(current.first, /badg badg-sm mr3 c-red">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 = /<a href="https:\/\/example\.test\/author\/5"><span class="avatar-mini"><img class="avatar"[^>]*><img class="avatar-badge"[^>]*><\/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, /<img class="avatar-mini"/, 'the renderer must not fall back to a plain avatar image');
assert.ok(current.avatar_calls.length > 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, /<posts class="posts-item list ajax-item style2 flex xx"/);
assert.match(current.first, /class="post-graphic"/);
assert.match(current.first, /class="item-excerpt muted-color text-ellipsis-2 mb6"/);
assert.match(current.first, /class="meta-author flex ac"/);
assert.doesNotMatch(current.first, /posts-row|no-thumb|mult-thumb|no_margin/);
});
test('card tabs render standalone cards without the archive list excerpt', () => {
const current = render().card;
assert.match(current.tab_markup, /widget-tab-post style-card index-tab/);
assert.match(current.first, /<posts class="posts-item card ajax-item style3"/);
assert.match(current.first, /class="item-thumbnail/);
assert.match(current.first, /class="item-body"/);
assert.doesNotMatch(current.first, /posts-row|item-excerpt|mult-thumb|no-thumb/);
});
test('blank tab titles are skipped while active and lazy panes retain matching segment identities', () => {
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">&hellip;<\/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(/<input\b[^>]*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(/<input\b[^>]*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|<aside>|<\/aside>/);
const frontend = read('src/WidgetSystem/FrontendRenderer.php');
const routeStart = frontend.indexOf("if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts']");
const contentRouteStart = frontend.indexOf('if (in_array($definition->slug()', routeStart + 1);
const referenceRoute = frontend.slice(routeStart, contentRouteStart);
const contentRoute = frontend.slice(contentRouteStart, frontend.indexOf('private static function render_reference_widget'));
assert.match(referenceRoute, /'user_card', 'author_card', 'tab_posts'/);
assert.doesNotMatch(contentRoute, /'tab_posts'/);
});
test('widget list and card CSS is included in the public page bundle at desktop and mobile breakpoints', () => {
const css = fs.readFileSync(path.join(root, 'assets/css/frontend/post-card.css'), 'utf8');
const mainCss = fs.readFileSync(path.join(root, 'assets/css/main.css'), 'utf8');
const builder = fs.readFileSync(path.join(root, 'tools/build-public-assets.mjs'), 'utf8');
assert.match(builder, /'homepage\.css': \[[\s\S]*?'assets\/css\/frontend\/post-card\.css'/);
assert.match(css, /\.posts-mini-lists \.tab-nav-theme/);
assert.match(css, /\.widget-tab-post\.index-tab \.tab-content > \.tab-pane/);
assert.match(css, /\.widget-tab-post\.style-list \.posts-item/);
assert.match(css, /@media \(max-width: 767px\)[\s\S]*?\.widget-tab-post\.index-tab \.tab-content > \.tab-pane/);
assert.match(mainCss, /\.tab-nav-theme \{/);
assert.match(mainCss, /\.index-tab ul > li\.active \{/);
});
@@ -0,0 +1,250 @@
<?php
/** Render tab post widgets with the production article renderer and fixture posts. */
declare(strict_types=1);
namespace {
error_reporting(E_ALL);
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) return false;
throw new \ErrorException($message, 0, $severity, $file, $line);
});
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
define('DAY_IN_SECONDS', 86400);
class WP_User { public function __construct(public int $ID, public string $display_name = '测试作者') {} }
class WP_Post { public function __construct(public int $ID, public int $post_author = 5) {} }
class WP_Widget {
public string $id_base = '';
public string $id = '';
public int $number = 1;
public function __construct(string $id_base = '', string $name = '', array $widget_options = [], array $control_options = []) {
unset($name, $widget_options, $control_options);
$this->id_base = $id_base;
}
}
class WP_Query {
public array $posts = [];
public int $found_posts = 0;
public int $max_num_pages = 1;
private int $cursor = 0;
public function __construct(array $args = []) {
$rows = array_values(array_filter($GLOBALS['tab_fixture_posts'], static function (array $post) use ($args): bool {
$include = array_map('intval', (array) ($args['category__in'] ?? []));
$exclude = array_map('intval', (array) ($args['category__not_in'] ?? []));
return (!$include || in_array($post['cat'], $include, true)) && !in_array($post['cat'], $exclude, true);
}));
$orderby = (string) ($args['orderby'] ?? 'date');
$order = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 1 : -1;
if ($orderby === 'rand') {
usort($rows, static fn(array $a, array $b): int => $a['id'] <=> $b['id']);
} else {
usort($rows, static fn(array $a, array $b): int => $order * ($a[$orderby] <=> $b[$orderby]));
}
$this->found_posts = count($rows);
$per_page = max(1, (int) ($args['posts_per_page'] ?? 6));
$this->max_num_pages = (int) ceil($this->found_posts / $per_page);
$page = max(1, (int) ($args['paged'] ?? 1));
$this->posts = array_map(static fn(array $post): WP_Post => new WP_Post((int) $post['id']), array_slice($rows, ($page - 1) * $per_page, $per_page));
}
public function have_posts(): bool { return $this->cursor < count($this->posts); }
public function the_post(): void {
$GLOBALS['tab_fixture_current_post'] = $this->posts[$this->cursor++];
$GLOBALS['post'] = $GLOBALS['tab_fixture_current_post'];
}
}
}
namespace AKSRT\Settings {
final class Settings {
public static function get_post_list_style_settings(): array {
return $GLOBALS['tab_fixture_global_style'] ?? [];
}
public static function get_post_thumb_settings(): array { return ['thumb_catimg_s' => false]; }
public static function get_post_page_settings(): array { return ['like_enabled' => true]; }
public static function get_comment_display_settings(): array { return ['close_comments' => false]; }
}
}
namespace {
$GLOBALS['tab_fixture_posts'] = [];
foreach (range(1, 48) as $id) {
$GLOBALS['tab_fixture_posts'][] = ['id' => $id, 'cat' => $id <= 36 ? 7 : 8, 'date' => 1000 + $id, 'modified' => 2000 + $id, 'views' => 49 - $id];
}
$GLOBALS['tab_fixture_current_post'] = null;
$GLOBALS['tab_fixture_global_style'] = [];
$GLOBALS['tab_fixture_mobile'] = false;
$GLOBALS['tab_fixture_avatar_calls'] = [];
function __($text, $domain = null): string { return (string) $text; }
function esc_html__($text, $domain = null): string { return esc_html((string) $text); }
function esc_attr__($text, $domain = null): string { return esc_attr((string) $text); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html($value); }
function esc_url($value): string { return esc_attr($value); }
function esc_url_raw($value): string { return (string) $value; }
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><strong><em><br><p><span>'); }
function wp_kses($value, array $allowed): string { unset($allowed); return (string) $value; }
function wp_parse_args($args, array $defaults = []): array { return array_merge($defaults, is_array($args) ? $args : []); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_html_class($value): string { return preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value) ?? ''; }
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function absint($value): int { return abs((int) $value); }
function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); }
function wp_trim_words($value, int $num_words = 55, string $more = '...'): string { return implode(' ', array_slice(preg_split('/\s+/', trim((string) $value)) ?: [], 0, $num_words)) . $more; }
function wp_is_mobile(): bool { return (bool) $GLOBALS['tab_fixture_mobile']; }
function aksrt_is_show_sidebar(): bool { return true; }
function is_category(): bool { return false; }
function is_search(): bool { return false; }
function is_home(): bool { return false; }
function is_front_page(): bool { return false; }
function is_author(): bool { return true; }
function is_tag(): bool { return false; }
function get_the_ID(): int { return (int) ($GLOBALS['tab_fixture_current_post']->ID ?? 0); }
function strip_shortcodes(string $value): string { return $value; }
function get_the_category(int $id = 0): array { unset($id); return []; }
function get_the_tags(int $id = 0): array { unset($id); return []; }
function get_the_excerpt(int $id = 0): string { unset($id); return '首页通用文章列表的长摘要,这里只用于卡片和列表样式的内容渲染'; }
function get_post_field(string $field, int $id = 0, string $context = 'display'): string {
unset($id, $context);
if ($field === 'post_author') return '5';
if ($field === 'post_content') return '<img src="https://example.test/gallery/1.jpg"><img src="https://example.test/gallery/2.jpg"><img src="https://example.test/gallery/3.jpg">';
return 'article body';
}
function get_userdata(int $id): WP_User { return new WP_User($id); }
function get_avatar_url(int $id, array $args = []): string { unset($args); return 'https://example.test/avatar/' . $id . '.png'; }
function aksrt_get_avatar_box($subject, string $class = 'avatar-img', bool $link = true, bool $vip = true): string {
$user_id = is_numeric($subject) ? (int) $subject : 0;
$GLOBALS['tab_fixture_avatar_calls'][] = ['user_id' => $user_id, 'class' => $class, 'link' => $link, 'vip' => $vip];
$inner = '<span class="' . esc_attr($class) . '"><img class="avatar" src="https://example.test/avatar/' . $user_id . '.png" alt=""><img class="avatar-badge" src="https://example.test/vip/' . $user_id . '.svg" alt="VIP"></span>';
return $link && $user_id > 0 ? '<a href="https://example.test/author/' . $user_id . '">' . $inner . '</a>' : $inner;
}
function get_the_time(string $format = '', $post = null): string { unset($post); return $format === 'U' ? '1000' : '2026-01-01 00:00:00'; }
function get_the_modified_time(string $format = '', $post = null): string { unset($post); return $format === 'U' ? '2000' : '2026-02-01 00:00:00'; }
function current_time(string $type): int { unset($type); return 3000; }
function human_time_diff(int $from, int $to): string { unset($to); return $from === strtotime('2026-01-01 00:00:00') ? '发布' : '修改'; }
function get_permalink(int $id = 0): string { return 'https://example.test/post/' . $id; }
function get_the_title(int $id = 0): string { return '真实文章 ' . $id; }
function get_post_thumbnail_id(int $id = 0): int { return $id; }
function get_post_meta(int $id, string $key = '', bool $single = false) { unset($id, $key, $single); return []; }
function wp_get_attachment_image_url(int $id, string $size = 'medium'): string { unset($size); return 'https://example.test/image/' . $id . '.jpg'; }
function attachment_url_to_postid(string $url): int { unset($url); return 0; }
function get_post_format(int $id = 0): string|false { unset($id); return false; }
function has_post_format($format, int $id = 0): bool { unset($format, $id); return false; }
function get_comments_number(int $id = 0): int { return $id; }
function comments_open(int $id = 0): bool { unset($id); return true; }
function get_comments_link(int $id = 0): string { return 'https://example.test/post/' . $id . '#comments'; }
function get_post_type(int $id = 0): string { unset($id); return 'post'; }
function is_sticky(int $id = 0): bool { unset($id); return false; }
function aksrt_resource_card_badge_text(int $id): string { unset($id); return ''; }
function aksrt_get_pay_meta($post = null): array { unset($post); return ['pay_type' => 'no']; }
function aksrt_get_post_views(int $id): int { return $id * 10; }
function aksrt_get_post_likes(int $id): int { return $id * 2; }
function aksrt_format_number(int $number): string { return (string) $number; }
function aksrt_get_lazy_thumb(string $size = ''): string { unset($size); return 'https://example.test/placeholder.jpg'; }
function aksrt_get_lazy_attr(string $key, string $url, string $class = '', string $lazy = ''): string { unset($key, $lazy); return ' class="' . esc_attr($class) . '" src="' . esc_url($url) . '"'; }
function aksrt_icon_markup(string $key): string { return '<svg class="' . esc_attr($key) . '"></svg>'; }
function wp_reset_postdata(): void { $GLOBALS['tab_fixture_current_post'] = null; $GLOBALS['post'] = null; }
function aksrt_widget_ajax_null(string $message = ''): string { return '<div class="aksrt-widget__empty">' . esc_html($message) . '</div>'; }
function aksrt_render_placeholder(string $style, int $count): string { return '<div data-placeholder="' . esc_attr($style) . ':' . $count . '"></div>'; }
function get_template_directory(): string { return dirname(__DIR__, 2); }
function get_template_directory_uri(): string { return 'https://example.test/theme'; }
function rest_url(string $path): string { return 'https://example.test/wp-json/' . $path; }
function wp_register_style(string $handle, string $src = '', array $deps = [], $version = false): bool { return true; }
function wp_register_script(string $handle, string $src = '', array $deps = [], $version = false, $in_footer = false): bool { return true; }
function wp_enqueue_style(string $handle): void {}
function wp_enqueue_script(string $handle): void {}
function wp_add_inline_script(string $handle, string $data, string $position = 'after'): void {}
function wp_json_encode($value): string { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); }
require dirname(__DIR__, 2) . '/vendor/autoload.php';
require dirname(__DIR__, 2) . '/inc/helpers/posts-renderer/config.php';
require dirname(__DIR__, 2) . '/inc/helpers/posts-renderer/post-data.php';
require dirname(__DIR__, 2) . '/inc/helpers/posts-renderer/markup.php';
$GLOBALS['tab_fixture_global_style'] = ['list_type' => 'card', 'list_show_type' => 'no_margin', 'list_list_option' => ['style' => 'style2'], 'list_card_option' => ['style' => 'style3'], 'list_orderby' => 'date', 'post_time_source' => 'modified', 'post_list_author' => true, 'post_list_time' => true];
$tabs = [
['title' => '热门', 'cat' => '7', 'orderby' => 'date'],
['title' => '', 'cat' => '7'],
['title' => '最近', 'cat' => '8', 'orderby' => 'modified'],
];
$cases = [];
$list_instance = [];
foreach (['default', 'invalid', 'mini', 'list', 'card'] as $name) {
$GLOBALS['tab_fixture_global_style']['list_type'] = $name === 'list' ? 'text' : 'card';
$instance = ['count' => 4, 'paginate' => 'number', 'mini_opt' => [], 'tabs' => $tabs];
if ($name === 'mini' || $name === 'list' || $name === 'card') $instance['style'] = $name;
if ($name === 'invalid') $instance['style'] = 'unknown';
if ($name === 'default' || $name === 'invalid') {
$cases['raw_' . $name] = [
'tab_markup' => \AKSRT\WidgetSystem\Renderers\ContentRenderer::render('tab_posts', $instance, ['widget_id' => 'aksrt_widget_tab_posts-4']),
'first' => \AKSRT\WidgetSystem\PostRenderer::payload($instance, 'tab_posts', 1, '0')['html'],
];
}
$instance = \AKSRT\WidgetSystem\FieldSchema::normalize(\AKSRT\WidgetSystem\WidgetSchemas::for('tab_posts'), $instance);
if ($name === 'list') $list_instance = $instance;
$avatar_start = count($GLOBALS['tab_fixture_avatar_calls']);
$first_page = \AKSRT\WidgetSystem\PostRenderer::payload($instance, 'tab_posts', 1, '0');
$second_page = \AKSRT\WidgetSystem\PostRenderer::payload($instance, 'tab_posts', 2, '0');
$cases[$name] = [
'instance_style' => $instance['style'],
'tab_markup' => \AKSRT\WidgetSystem\Renderers\ContentRenderer::render('tab_posts', $instance, ['widget_id' => 'aksrt_widget_tab_posts-4']),
'first' => $first_page['html'],
'second' => $second_page['html'],
'pagination' => $first_page['paginate_html'],
'last_pagination' => \AKSRT\WidgetSystem\PostRenderer::payload($instance, 'tab_posts', 9, '0')['paginate_html'],
'avatar_calls' => array_slice($GLOBALS['tab_fixture_avatar_calls'], $avatar_start),
];
}
$mini_instance = ['style' => 'mini', 'mini_opt' => ['show_thumb', 'show_number', 'show_meta'], 'count' => 4, 'paginate' => '', 'tabs' => $tabs];
$mini_instance = \AKSRT\WidgetSystem\FieldSchema::normalize(\AKSRT\WidgetSystem\WidgetSchemas::for('tab_posts'), $mini_instance);
$avatar_start = count($GLOBALS['tab_fixture_avatar_calls']);
$mini_first = \AKSRT\WidgetSystem\PostRenderer::payload($mini_instance, 'tab_posts', 1, '0')['html'];
$mini_second = \AKSRT\WidgetSystem\PostRenderer::payload($mini_instance, 'tab_posts', 2, '0')['html'];
$cases['mini_all_options'] = [
'first' => $mini_first,
'second' => $mini_second,
'avatar_calls' => array_slice($GLOBALS['tab_fixture_avatar_calls'], $avatar_start),
];
$GLOBALS['tab_fixture_global_style']['post_list_author'] = false;
$mini_time_instance = ['style' => 'mini', 'mini_opt' => ['show_meta'], 'count' => 4, 'paginate' => '', 'tabs' => $tabs];
$mini_time_instance = \AKSRT\WidgetSystem\FieldSchema::normalize(\AKSRT\WidgetSystem\WidgetSchemas::for('tab_posts'), $mini_time_instance);
$cases['mini_time_source'] = ['html' => \AKSRT\WidgetSystem\PostRenderer::payload($mini_time_instance, 'tab_posts', 1, '0')['html']];
$GLOBALS['tab_fixture_global_style']['post_list_author'] = true;
$ajax_instance = $instance;
$ajax_instance['style'] = 'mini';
$ajax_instance['paginate'] = 'ajax';
$ajax_instance = \AKSRT\WidgetSystem\FieldSchema::normalize(\AKSRT\WidgetSystem\WidgetSchemas::for('tab_posts'), $ajax_instance);
$cases['ajax_pagination'] = [
'first' => \AKSRT\WidgetSystem\PostRenderer::payload($ajax_instance, 'tab_posts', 1, '0')['paginate_html'],
'last' => \AKSRT\WidgetSystem\PostRenderer::payload($ajax_instance, 'tab_posts', 9, '0')['paginate_html'],
];
$GLOBALS['tab_fixture_mobile'] = true;
$cases['mobile_pagination'] = [
'middle' => \AKSRT\WidgetSystem\PostRenderer::payload($list_instance, 'tab_posts', 5, '0')['paginate_html'],
'last' => \AKSRT\WidgetSystem\PostRenderer::payload($list_instance, 'tab_posts', 9, '0')['paginate_html'],
];
$GLOBALS['tab_fixture_mobile'] = false;
$cases['context_margins'] = [
'implicit' => \aksrt_renderer_config(['type' => 'list'])['no_margin'],
'explicit_false' => \aksrt_renderer_config(['type' => 'list', 'no_margin' => false])['no_margin'],
];
$definition = \AKSRT\WidgetSystem\WidgetRegistry::get('tab_posts');
$widget = new \AKSRT\WidgetSystem\SchemaWidget($definition);
$widget->id = 'aksrt_widget_tab_posts-4';
$wrapper_instance = \AKSRT\WidgetSystem\FieldSchema::normalize($definition->fields(), [
'title' => '参考标题',
'layout_pin_pc' => ['top' => 8, 'right' => 6, 'bottom' => 18, 'left' => 4],
'layout_pin_m' => ['top' => 3, 'right' => 2, 'bottom' => 10, 'left' => 2],
'layout_bg' => [
'light' => ['color' => '#ffffff', 'image' => 'https://example.test/light.jpg'],
'dark' => ['color' => '#111111', 'image' => 'https://example.test/dark.jpg'],
],
'tabs' => $tabs,
]);
ob_start();
\AKSRT\WidgetSystem\FrontendRenderer::render($definition, ['before_widget' => '<aside>', 'after_widget' => '</aside>'], $wrapper_instance, $widget);
$cases['frontend_wrapper'] = (string) ob_get_clean();
echo json_encode(['cases' => $cases], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,530 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
class MiniElement {
constructor(tagName = 'div') {
this.tagName = String(tagName).toUpperCase();
this.parentNode = null;
this.children = [];
this.attributes = {};
this.dataset = {};
this.listeners = {};
this.offsetLeft = 0;
this.offsetWidth = 0;
this.scrollWidth = 0;
this.clientWidth = 0;
this.scrollLeft = 0;
this.rectLeft = 0;
this.rectWidth = 0;
this.scrollCalls = [];
this._text = '';
this._classes = new Set();
this.classList = {
add: (...tokens) => tokens.forEach((token) => this._classes.add(token)),
remove: (...tokens) => tokens.forEach((token) => this._classes.delete(token)),
contains: (token) => this._classes.has(token),
toggle: (token, force) => {
const shouldAdd = force === undefined ? !this._classes.has(token) : Boolean(force);
if (shouldAdd) this._classes.add(token);
else this._classes.delete(token);
return shouldAdd;
},
};
}
get className() { return Array.from(this._classes).join(' '); }
set className(value) { this._classes = new Set(String(value || '').split(/\s+/).filter(Boolean)); }
get id() { return this.getAttribute('id') || ''; }
set id(value) { this.setAttribute('id', value); }
get max() { return this.getAttribute('max') || ''; }
set max(value) { this.setAttribute('max', value); }
get value() { return this._value || ''; }
set value(value) { this._value = String(value); }
get parentElement() { return this.parentNode && this.parentNode.tagName !== '#DOCUMENT' ? this.parentNode : null; }
get firstChild() { return this.children[0] || null; }
get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); }
set textContent(value) { this._text = String(value); this.replaceChildren(); }
get innerHTML() { return this.children.map((child) => child.outerHTML || child.textContent).join(''); }
set innerHTML(markup) {
this.replaceChildren();
parseMarkup(String(markup || ''), this);
}
setAttribute(name, value) {
const text = String(value);
this.attributes[name] = text;
if (name === 'class') this.className = text;
if (name.startsWith('data-')) this.dataset[toDatasetKey(name.slice(5))] = text;
}
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
hasAttribute(name) { return Object.hasOwn(this.attributes, name); }
appendChild(child) {
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.push(child);
return child;
}
insertBefore(child, anchor) {
if (!anchor || anchor.parentNode !== this) return this.appendChild(child);
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.splice(this.children.indexOf(anchor), 0, child);
return child;
}
replaceChildren(...nodes) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
nodes.forEach((node) => this.appendChild(node));
}
remove() {
if (!this.parentNode) return;
const siblings = this.parentNode.children;
const index = siblings.indexOf(this);
if (index !== -1) siblings.splice(index, 1);
this.parentNode = null;
}
addEventListener(type, callback) {
(this.listeners[type] ||= []).push(callback);
}
dispatch(type, properties = {}) {
const event = {
type,
target: this,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.propagationStopped = true; },
...properties,
};
let node = this;
while (node) {
for (const listener of node.listeners[type] || []) listener(event);
if (event.propagationStopped) break;
node = node.parentNode;
}
return event;
}
click() { return this.dispatch('click'); }
focus() { this.ownerDocument.activeElement = this; }
closest(selector) {
for (let node = this; node; node = node.parentNode) {
if (matchesCompound(node, selector)) return node;
}
return null;
}
matches(selector) { return matchesCompound(this, selector); }
querySelectorAll(selector) {
const directOnly = selector.startsWith(':scope > ');
const query = directOnly ? selector.slice(9) : selector;
const nodes = directOnly ? this.children.slice() : descendants(this);
return nodes.filter((node) => matchesSelector(node, query));
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
insertAdjacentHTML(position, markup) {
const fragment = this.ownerDocument.createElement('div');
fragment.innerHTML = markup;
const nodes = fragment.children.slice();
if (position === 'afterbegin') nodes.reverse().forEach((node) => this.insertBefore(node, this.firstChild));
else if (position === 'beforebegin' && this.parentNode) nodes.forEach((node) => this.parentNode.insertBefore(node, this));
else nodes.forEach((node) => this.appendChild(node));
}
scrollTo(options) { this.scrollCalls.push(options); }
getBoundingClientRect() { return { left: this.rectLeft, width: this.rectWidth || this.offsetWidth }; }
}
class MiniDocument extends MiniElement {
constructor() {
super('#document');
this.ownerDocument = this;
this.readyState = 'complete';
this.body = this.createElement('body');
this.appendChild(this.body);
this.activeElement = null;
}
createElement(tagName) {
const element = new MiniElement(tagName);
element.ownerDocument = this;
return element;
}
}
function toDatasetKey(value) { return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); }
function descendants(node) {
return node.children.flatMap((child) => [child, ...descendants(child)]);
}
function matchesCompound(node, selector) {
if (!selector || selector === '*') return true;
const tag = selector.match(/^[a-z][a-z0-9-]*/i);
if (tag && node.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
const id = selector.match(/#([a-zA-Z_][\w-]*)/);
if (id && node.getAttribute('id') !== id[1]) return false;
for (const [, className] of selector.matchAll(/\.([a-zA-Z_][\w-]*)/g)) {
if (!node.classList.contains(className)) return false;
}
for (const [, name, expected] of selector.matchAll(/\[([\w-]+)(?:=["']?([^\]"']+)["']?)?\]/g)) {
if (!node.hasAttribute(name)) return false;
if (expected !== undefined && node.getAttribute(name) !== expected) return false;
}
return true;
}
function matchesSelector(node, selector) {
const tokens = selector.replace(/>/g, ' > ').trim().split(/\s+/);
let cursor = node;
if (!matchesCompound(cursor, tokens.pop())) return false;
while (tokens.length) {
const combinator = tokens.pop();
const previous = tokens.pop();
if (combinator === '>') {
cursor = cursor.parentNode;
if (!cursor || !matchesCompound(cursor, previous)) return false;
} else {
cursor = cursor.parentNode;
while (cursor && !matchesCompound(cursor, previous)) cursor = cursor.parentNode;
if (!cursor) return false;
}
}
return true;
}
function parseMarkup(markup, parent) {
const tokens = markup.match(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g) || [];
const stack = [parent];
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
for (const token of tokens) {
if (token.startsWith('<!--') || token.trim() === '') continue;
if (token.startsWith('</')) {
if (stack.length > 1) stack.pop();
continue;
}
if (!token.startsWith('<')) {
const textNode = parent.ownerDocument.createElement('#text');
textNode._text = token;
stack[stack.length - 1].appendChild(textNode);
continue;
}
const open = token.match(/^<([a-z][\w-]*)([\s\S]*?)\/?\s*>$/i);
if (!open) continue;
const node = parent.ownerDocument.createElement(open[1]);
for (const [, name, quoted, bare] of open[2].matchAll(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
node.setAttribute(name, quoted ?? bare ?? '');
}
stack[stack.length - 1].appendChild(node);
if (!voidTags.has(node.tagName.toLowerCase()) && !token.endsWith('/>')) stack.push(node);
}
}
function tabMarkup(document, { firstPage = 1, firstSegmentLoaded = true } = {}) {
const root = document.createElement('div');
root.className = 'widget-tab-post style-mini posts-mini-lists zib-widget';
root.setAttribute('data-widget', 'tab_posts');
root.setAttribute('data-instance', 'widget-7');
const nav = document.createElement('ul');
nav.className = 'list-inline scroll-x no-scrollbar';
nav.setAttribute('role', 'tablist');
nav.clientWidth = 260;
nav.scrollWidth = 420;
const tabOne = addTab(document, nav, 0, 'tab-one', 'panel-one', true);
const tabTwo = addTab(document, nav, 1, 'tab-two', 'panel-two', false);
nav.rectLeft = 20;
tabOne.rectLeft = 100;
tabOne.rectWidth = 100;
tabTwo.rectLeft = 380;
tabTwo.rectWidth = 100;
root.appendChild(nav);
const content = document.createElement('div');
content.className = 'tab-content';
content.appendChild(addPane(document, 'panel-one', 'tab-one', 0, firstPage, firstSegmentLoaded, true));
content.appendChild(addPane(document, 'panel-two', 'tab-two', 1, 1, false, false));
root.appendChild(content);
return { root, nav, tabOne, tabTwo };
}
function addTab(document, nav, segment, id, panelId, selected) {
const item = document.createElement('li');
if (selected) item.classList.add('active');
const tab = document.createElement('a');
tab.setAttribute('role', 'tab');
tab.setAttribute('id', id);
tab.setAttribute('aria-controls', panelId);
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
tab.setAttribute('tabindex', selected ? '0' : '-1');
tab.setAttribute('data-segment', String(segment));
item.appendChild(tab);
nav.appendChild(item);
return tab;
}
function addPane(document, id, tabId, segment, page, loaded, active) {
const pane = document.createElement('div');
pane.className = 'tab-pane fade' + (active ? ' active in' : '');
pane.setAttribute('id', id);
pane.setAttribute('role', 'tabpanel');
pane.setAttribute('aria-labelledby', tabId);
const pager = document.createElement('div');
pager.className = 'widget-ajaxpager';
pager.setAttribute('data-widget-items', '');
pager.setAttribute('data-widget', 'tab_posts');
pager.setAttribute('data-instance', 'widget-7');
pager.setAttribute('data-segment', String(segment));
pager.setAttribute('data-page', String(page));
if (loaded) pager.setAttribute('data-loaded', '1');
if (active && loaded) {
pager.innerHTML = '<posts class="posts-mini ajax-item" data-post-id="post-one"><div class="posts-mini-con"><h2 class="item-heading"><a>Page one</a></h2></div></posts>';
pager.appendChild(nextPage(document, 2));
} else {
const loader = document.createElement('div');
loader.className = 'post_ajax_loader';
pager.appendChild(loader);
}
pane.appendChild(pager);
return pane;
}
function nextPage(document, page) {
const pagination = document.createElement('div');
pagination.className = 'ajax-pag';
const link = document.createElement('a');
link.setAttribute('data-next-page', String(page));
pagination.appendChild(link);
return pagination;
}
function addJumpControl(document, pager, { max = 20, value = '4' } = {}) {
const pagination = document.createElement('div');
pagination.className = 'ajax-pag';
const wrapper = document.createElement('span');
wrapper.className = 'pag-jump';
const input = document.createElement('input');
input.setAttribute('data-widget-jump', '');
input.setAttribute('type', 'text');
input.max = max;
input.value = value;
const button = document.createElement('button');
button.setAttribute('data-widget-jump-go', '');
wrapper.appendChild(input);
wrapper.appendChild(button);
pagination.appendChild(wrapper);
pager.appendChild(pagination);
return { input, button };
}
function createRuntime({ fetch, pages } = {}) {
const document = new MiniDocument();
const { root: widget, nav, tabOne, tabTwo } = tabMarkup(document);
document.body.appendChild(widget);
const autoFun = [];
function jQuery() {
return {
on(event, callback) { if (event === 'auto_fun') autoFun.push(callback); return this; },
trigger(event) { if (event === 'auto_fun') autoFun.forEach((callback) => callback()); return this; },
};
}
const requests = [];
const config = { restUrl: '/wp-json/aksrt/v1', i18n: { error: 'error', retry: 'retry' } };
const fetchStub = fetch || ((url) => {
requests.push(url);
const page = Number(new URL(url, 'https://example.test').searchParams.get('page'));
const segment = new URL(url, 'https://example.test').searchParams.get('segment');
const response = (pages && pages[`${segment}:${page}`]) || {
html: `<posts class="posts-mini ajax-item" data-post-id="post-${segment}-${page}"><div class="posts-mini-con"><h2 class="item-heading"><a>Returned ${segment}/${page}</a></h2></div></posts>`,
page,
paginate_html: page < 3 ? `<div class="ajax-pag"><a data-next-page="${page + 1}">more</a></div>` : '',
};
return Promise.resolve({ ok: true, json: () => Promise.resolve(response) });
});
const window = {
aksrtWidgets: config,
matchMedia: () => ({ matches: false }),
jQuery,
};
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/widgets/frontend.js'), 'utf8'), {
window,
document,
fetch: fetchStub,
Promise,
Array,
Number,
String,
Object,
Math,
encodeURIComponent,
MutationObserver: undefined,
});
return { document, widget, nav, tabOne, tabTwo, requests, autoFun };
}
const tick = () => new Promise((resolve) => setImmediate(resolve));
const pageRequests = (requests) => requests.map((url) => Number(new URL(url, 'https://example.test').searchParams.get('page')));
test('tab panes cache results and keep their current page after switching away and back', async () => {
const runtime = createRuntime();
const paneOne = runtime.widget.querySelector('#panel-one');
const paneTwo = runtime.widget.querySelector('#panel-two');
const pagerOne = paneOne.querySelector('[data-widget-items]');
const pagerTwo = paneTwo.querySelector('[data-widget-items]');
runtime.tabTwo.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1]);
const pageTwoMore = pagerTwo.querySelector('[data-next-page="2"]');
assert.ok(pageTwoMore, 'the returned REST markup is parsed and inserted into the selected pane');
pageTwoMore.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1, 2]);
assert.equal(pagerTwo.dataset.page, '2');
runtime.tabOne.click();
runtime.tabTwo.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1, 2], 'a cached pane does not issue another first-page request');
assert.equal(pagerOne.dataset.page, '1', 'switching tabs preserves the initial pane page');
assert.equal(pagerTwo.dataset.page, '2', 'switching back preserves the loaded pane page');
assert.equal(pagerTwo.querySelectorAll('[data-post-id]').length, 2, 'returned page markup remains in the cached pane');
});
test('arrow keys move tab focus, activate the adjacent pane, and center an overflowing nav item', async () => {
const runtime = createRuntime();
const keyEvent = runtime.tabOne.dispatch('keydown', { key: 'ArrowRight' });
await tick();
assert.equal(keyEvent.defaultPrevented, true);
assert.equal(runtime.document.activeElement, runtime.tabTwo);
assert.equal(runtime.tabTwo.getAttribute('aria-selected'), 'true');
assert.equal(runtime.widget.querySelector('#panel-two').classList.contains('active'), true);
assert.equal(runtime.nav.scrollCalls.length, 1);
assert.equal(runtime.nav.scrollCalls[0].left, 280);
assert.equal(runtime.nav.scrollCalls[0].behavior, 'smooth');
assert.deepEqual(pageRequests(runtime.requests), [1]);
});
test('overflowing tab centering includes the nav current scroll offset', () => {
const runtime = createRuntime();
runtime.nav.scrollLeft = 35;
runtime.tabTwo.click();
assert.equal(runtime.nav.scrollCalls.length, 1);
assert.equal(runtime.nav.scrollCalls[0].left, 315, 'the target uses viewport bounds plus current nav scrollLeft');
});
test('a failed load-more can retry page two and append it without replacing page one', async () => {
let pageTwoAttempts = 0;
const runtime = createRuntime({ fetch: (url) => {
runtime.requests.push(url);
const page = Number(new URL(url, 'https://example.test').searchParams.get('page'));
if (page === 2 && pageTwoAttempts++ === 0) return Promise.reject(new Error('temporary failure'));
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
html: '<posts class="posts-mini ajax-item" data-post-id="post-two"><h2 class="item-heading"><a>Page two</a></h2></posts>',
page,
paginate_html: '',
}),
});
} });
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
pager.querySelector('[data-next-page="2"]').click();
await tick();
const retry = pager.querySelector('[data-widget-retry]');
assert.ok(retry, 'a failed append exposes the runtime retry control');
assert.equal(retry.dataset.retryPage, '2');
assert.equal(retry.dataset.retryAppend, '1');
retry.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [2, 2], 'retry repeats the failed page rather than resetting to page one');
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']);
assert.equal(pager.dataset.page, '2');
assert.equal(runtime.autoFun.length, 1, 'successful markup insertion initializes widgets through auto_fun');
});
test('jump button replaces the selected pane, keeps its loader, and stops the legacy click handler', async () => {
const runtime = createRuntime();
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const loader = runtime.document.createElement('div');
loader.className = 'post_ajax_loader';
pager.insertBefore(loader, pager.querySelector('.ajax-pag'));
const { button } = addJumpControl(runtime.document, pager, { value: '4' });
let legacyClicks = 0;
runtime.document.body.addEventListener('click', () => { legacyClicks += 1; });
const click = button.dispatch('click');
await tick();
assert.equal(click.defaultPrevented, true);
assert.equal(click.propagationStopped, true);
assert.equal(legacyClicks, 0, 'the legacy pag_jump_go document handler cannot process the same click');
assert.deepEqual(pageRequests(runtime.requests), [4]);
assert.equal(pager.dataset.page, '4');
assert.equal(pager.querySelector('[data-post-id="post-0-4"]') !== null, true, 'the returned page markup replaces the active pane items');
assert.equal(pager.querySelector('[data-post-id="post-one"]'), null);
assert.equal(pager.querySelector('.post_ajax_loader'), loader, 'replacement preserves the loader node for subsequent requests');
});
test('Enter submits a valid jump and stops the legacy key handler', async () => {
const runtime = createRuntime();
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const loader = runtime.document.createElement('div');
loader.className = 'post_ajax_loader';
pager.insertBefore(loader, pager.querySelector('.ajax-pag'));
const { input } = addJumpControl(runtime.document, pager, { value: '5' });
let legacyKeydowns = 0;
runtime.document.body.addEventListener('keydown', () => { legacyKeydowns += 1; });
const keydown = input.dispatch('keydown', { key: 'Enter' });
await tick();
assert.equal(keydown.defaultPrevented, true);
assert.equal(keydown.propagationStopped, true);
assert.equal(legacyKeydowns, 0, 'the legacy pag_jump_go key handler cannot submit twice');
assert.deepEqual(pageRequests(runtime.requests), [5]);
assert.equal(pager.dataset.page, '5');
assert.ok(pager.querySelector('[data-post-id="post-0-5"]'));
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
});
test('jump input clamps pages to max and rejects invalid or current pages without fetching', async () => {
const runtime = createRuntime();
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const { input } = addJumpControl(runtime.document, pager, { max: 8, value: '99' });
const inputEvent = input.dispatch('input');
assert.equal(input.value, '8');
assert.equal(inputEvent.propagationStopped, true);
input.value = 'not-a-page';
input.dispatch('input');
assert.equal(input.value, '');
input.dispatch('keydown', { key: 'Enter' });
assert.equal(runtime.requests.length, 0, 'invalid input does not request a page');
input.value = '1';
const currentPage = input.dispatch('keydown', { key: 'Enter' });
await tick();
assert.equal(currentPage.defaultPrevented, true);
assert.equal(runtime.requests.length, 0, 'submitting the already selected page does not request again');
assert.equal(runtime.document.activeElement, input, 'invalid and current page values return focus to the input');
});
@@ -0,0 +1,81 @@
const test = require('node:test');
const assert = require('node:assert/strict');
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');
const css = read('assets/widgets/frontend.css');
function declarations(selector) {
const result = {};
for (const match of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!match[1].split(',').map((value) => value.trim()).includes(selector)) continue;
for (const declaration of match[2].split(';')) {
const colon = declaration.indexOf(':');
if (colon < 0) continue;
result[declaration.slice(0, colon).trim()] = declaration.slice(colon + 1).trim();
}
}
return result;
}
test('tab post titles restore reference weight after public typography while preserving the bold option', () => {
// public.css sets h2 to 700. The widget must override that selector;
// the explicit global heading-bold option must still win inside it.
assert.match(read('assets/css/core/typography.css'), /font-weight:\s*700/);
const heading = declarations('.widget-tab-post .item-heading');
assert.equal(heading['font-weight'], '500');
assert.equal(heading.color, 'inherit');
assert.equal(declarations('.widget-tab-post .item-heading.font-bold')['font-weight'], '700');
const shell = declarations('.aksrt-tab-posts-wrap');
assert.equal(shell['font-size'], '14px');
assert.equal(shell['line-height'], '1.42857143');
assert.equal(shell.color, 'var(--main-color,#4e5358)');
assert.equal(declarations('.widget-tab-post .posts-mini')['vertical-align'], 'baseline');
assert.equal(declarations('.widget-tab-post .posts-mini-con').flex, 'auto');
});
test('widget pagination restores compact reference metrics over public pagination overrides', () => {
assert.match(read('assets/css/frontend/pagination.css'), /\.pagenav\.ajax-pag span/);
const pager = declarations('.widget-tab-post .pagenav');
assert.equal(pager.display, 'block');
assert.equal(pager.padding, '20px 0');
const page = declarations('.widget-tab-post .pagenav .page-numbers');
// The final rule is the 640px override, checked separately below.
assert.equal(page.display, 'inline-block');
assert.equal(page.padding, '6px 9px 5px');
assert.equal(page['min-height'], '0');
assert.equal(page.background, 'var(--main-border-color)');
const nestedLabel = declarations('.widget-tab-post .pagenav .page-numbers > span');
assert.equal(nestedLabel.background, 'none');
assert.equal(nestedLabel['min-width'], '0');
assert.equal(nestedLabel.padding, '0');
const hover = declarations('.widget-tab-post .pagenav a.page-numbers:hover');
assert.equal(hover.background, 'var(--main-border-color)');
assert.equal(hover.opacity, '.7');
});
test('jump control keeps the reference inline layout and responsive numeric links', () => {
const jump = declarations('.widget-tab-post .pagenav .pag-jump');
assert.equal(jump.width, 'auto', 'public mobile CSS must not stretch the jump control to a full row');
assert.equal(jump.display, 'inline-flex');
const input = declarations('.widget-tab-post .pagenav .pag-jump .jump-input');
assert.equal(input.position, 'absolute');
assert.equal(input.width, '100%');
assert.equal(input.opacity, '0');
const mobile = css.slice(css.indexOf('@media (max-width:640px)'));
assert.match(mobile, /\.widget-tab-post \.pagenav a\.page-numbers\s*\{\s*display:none/);
assert.match(mobile, /\.widget-tab-post \.pagenav a\.prev\s*\{\s*display:inline-block/);
assert.match(mobile, /\.widget-tab-post \.pagenav \.page-numbers\s*\{\s*min-width:28px;\s*margin:2px;\s*font-size:12px/);
});
test('modern tabs have one interaction owner and no retired tab selector or unused widget-style field', () => {
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const runtime = read('assets/widgets/frontend.js');
const renderer = read('src/WidgetSystem/PostRenderer.php');
assert.doesNotMatch(content, /data-toggle="tab"|data-ajax/);
assert.doesNotMatch(runtime, /aksrt-widget-tabs/);
assert.doesNotMatch(renderer, /widget_style/);
assert.match(runtime, /scope\.querySelectorAll\('\.widget-tab-post'\)\.forEach\(initTabs\)/);
});