fix(widgets): align tabbed post layouts with reference
This commit is contained in:
1 parent
17515cf9c5
commit
fd034ff055
16 files changed
+1256
-37
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">…</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 构建及差异空白检查通过。
|
||||
@@ -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">…<\/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\)/);
|
||||
});
|
||||
Reference in new issue
Block a user