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
@@ -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