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

+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\)/);
});