fix(widgets): 分类挂件改成「点开才占一行」的下拉面板 #24

需求改判:3.2.54 做的「父级常显 + 子级折叠树」被否,用户要的是
「不点只有一行,点开把所有一级分类拉出来」的真下拉面板。

## 改了什么

- widget() 重写:`<details class="yang-cats-drop">` + `<summary>` 触发条
  + `position:absolute` 浮层面板。收起时整个挂件只占一行(40.5px),
  展开时面板盖在下方内容上,不把后面的挂件推走。
- children 兜底值 true → false。线上实例的配置里本来没有这个键,
  所以走 pick() 兜底 ⇒ 改兜底就等于改线上行为,用户零操作即变成
  「只列一级分类」,不用进后台勾一次。
- 子级不再嵌套 <details>(3.2.54 是嵌套)。现在父级与子级的 DOM
  结构完全一致,3.2.54 那个「space-between 推不动、数字右边界差
  7.1px」的缺陷从结构上消失了,不需要 flex:1 补丁。
- 补一段渐进增强脚本,只做「点外部收起 / Esc 收起并还焦点」两件事,
  不接管开合本身(关掉 JS 仍能正常展开跳转)。
- CSS:.w-cats 区块整块重写。修掉子级 li 右内边距叠加的缺陷 ——
  子级 ul 嵌在已 padding:0 12px 的父级 li 里,子级 li 又写了一遍
  右内边距 ⇒ 叠加成 24px,导致子级数字比父级多缩进 12px、父子排不成
  一列,竖着比大小时会错位。改成 0 后父子数字同列。
- style.css 版本号 3.2.54 → 3.2.55(3.2.54 已落过 update.json,
  同号重发会让 WP 的 version_compare 判「没有新版本」)。

## 验证

- 端到端(线上真实 WordPress,96 项):全通过
- 反证:内容判据 12 组 + 端到端 3 组,全部真红,无一组空跑
- 视觉几何(真实浏览器 50 项):全通过
- 上一版(3.2.53)老代码跑同一套端到端判据:88 项失败
- 包内 PHP lint 96 个全过;zip 无 1980 时间戳(防 OPcache 误判)

## 发布

只写 theme-update/:zip + readme + update.json → 3.2.55。
线上主题目录仍是 3.2.54,等用户在后台点升级(红线)。
This commit is contained in:
2026-10-27 13:00:37 +08:00
parent 4c1d67b86b
commit f5fd363dce
4 changed files with 388 additions and 274 deletions
+146 -53
View File
@@ -1101,15 +1101,28 @@ class widget_posts extends WP_Widget
* 沿用 `categories` 则 option 名不变,存量实例继续有效。
* 这也意味着 update() **必须容忍核心那套老字段**(dropdown 等),见下方说明。
*
* ── 「父级常显 + 子级可折叠」的呈现(用户 2026-10-27 选定)──────────────
* 本站实测 29 个分类(9 父 + 20 子)。全展开侧栏要拉长约 1100px,
* 所以默认只列父级,有子级的父级带一个折叠箭头,点开才展开。
* 🔴 折叠用 `<details>/<summary>` 原生实现,**不写一行 JS**:
* ── 呈现形态:收起一行 + 点击弹出面板(3.2.55 按用户反馈改判)────────────
* 3.2.54 先做成「父级常显 + 子级折叠树」,8 个父级铺开占约 300px 侧栏高度。
* 用户否决:「不点击的话就一行,这样占用的空间小,点击后把所有的一级分类
* 拉出来,类似于之前那样(指原生的 <select>),但样式要好看」。
*
* 所以现在是**真下拉面板**:
* 收起 → 一行触发条(当前分类名,不在分类页时显示「选择分类」+ 箭头)
* 展开 → 浮层面板,`position:absolute` 贴在触发条下方,**不推开下面的挂件**
* 内容 → 默认只列一级分类(本站 9 个);子分类是可选项(默认关)
*
* 🔴 仍然用 `<details>/<summary>` 做开合,**核心逻辑不写 JS**:
* - 不依赖 jQuery/Bootstrap 版本,不会跟主题其它脚本抢事件;
* - 键盘可操作、读屏软件能正确朗读,自带 `aria-expanded` 语义;
* - 关掉 JS 也能用(分类导航属于基础功能,不该依赖脚本)。
* `<summary>` 里同时放"父级链接 + 箭头按钮"会有嵌套点击问题,
* 所以箭头用 `::marker` 之外的独立 span,父级链接本身仍是普通 `<a>`。
* - 键盘可操作、读屏软件能正确朗读,自带展开/收起语义;
* - 关掉 JS 也能展开、也能点分类跳转(分类导航是基础功能)。
* JS 只做两件 `<details>` 原生没有的事:**点面板外部关闭、按 Esc 关闭**。
* 这段是渐进增强:脚本没跑,功能不缺,只是要再点一次触发条才收起来。
*
* 🔴 为什么面板能做绝对定位浮层而不被裁掉(实测过,不是猜的):
* 链路 `.k-main .sidebar .widget` → `.w-cats` → 面板,`sidebar` 与 `widget`
* 都**没有** `overflow` 声明(`body` 只有 `overflow-x:hidden`,不影响纵向),
* 所以面板可以溢出挂件边界。若将来谁给 `.widget` 加了 `overflow:hidden`,
* 面板会被整块裁掉 —— 这条写在这里就是给那时候看的。
*
* ── 父级数字为什么要 pad_counts(这条最容易做错)────────────────────────
* 核心 `wp_list_categories()` 在 `show_count && hierarchical` 时**自动**打开
@@ -1152,7 +1165,7 @@ class widget_categories extends WP_Widget
* 名字走第 2 参(工单 #23,理由见 widget_ad 的注释)。 */
$widget_ops = array(
'classname' => 'widget_categories',
'description' => __('按父子层级展示文章分类,父级常显、子级可折叠', 'kratos'),
'description' => __('以「点击展开」的下拉面板展示文章分类,收起时只占一行', 'kratos'),
);
parent::__construct('categories', __('文章分类', 'kratos'), $widget_ops);
@@ -1332,7 +1345,15 @@ class widget_categories extends WP_Widget
{
$show_count = $this->pick($instance, 'count', true);
$hide_empty = $this->pick($instance, 'hide_empty', true);
$show_children = $this->pick($instance, 'children', true);
/* 🔴 children 默认值 3.2.55 改成 **false**(3.2.54 是 true)。
*
* 用户的诉求原话是「点击后把所有**一级**分类拉出来」—— 默认就该
* 只有一级。线上那个实例 `categories-3` 是核心老配置,压根没有
* children 这个键 ⇒ 走 fallback ⇒ 改默认值就等于改线上呈现,
* 不需要用户去后台勾一下,也不用动数据库。
*
* 想连子级一起看的人,在后台勾上即可(update() 会存成 1)。 */
$show_children = $this->pick($instance, 'children', false);
$orderby = $this->pick($instance, 'orderby', 'name');
$order = $this->pick($instance, 'order', 'ASC');
@@ -1379,7 +1400,57 @@ class widget_categories extends WP_Widget
echo '<div class="' . esc_attr(yang_widget_class('w-cats')) . '">';
yang_widget_title($args, $instance, __('文章分类', 'kratos'), $this->id_base);
echo '<ul class="yang-cat-list">';
/* 🔴 当前分类名要显示在触发条上,让收起状态也能看出"我人在哪个分类"。
*
* 这里必须先把它找出来(而不是边遍历边拼),因为触发条**在面板之前**
* 输出 —— 面板内容才能边遍历边 echo。
*
* 只在 is_category() 时取,空串表示"不在分类页" ⇒ 触发条显示占位文案。
* 父级分类被访问时,$current_id 就是那个父级(不是它的子级),
* 所以不必再往祖先链上找。 */
$current_name = '';
if ($current_id > 0) {
foreach ($tree as $node) {
if ((int) $node['cat']->term_id === $current_id) {
$current_name = (string) $node['cat']->name;
break;
}
foreach ($node['children'] as $kid) {
if ((int) $kid->term_id === $current_id) {
$current_name = (string) $kid->name;
break 2;
}
}
}
}
/* 面板 DOM id:一页上可能有多个实例(虽然实际很少),必须唯一,
* 否则 `<summary>` 里若要写 aria-controls 会指到第一个。
* 用 yang_widget_uid() 拿 `widget_categories-3` 这类稳定串。 */
$panel_id = yang_widget_uid($this) . '-panel';
echo '<details class="yang-cats-drop">';
/* 🔴 触发条用 <summary>,**里面不放 <a>**。
*
* 3.2.54 那版把父级链接塞进 <summary> 里靠"<a> 是 interactive
* content 所以不触发展开"来分工(实测确实可行)。但这一版
* <summary> 的职责变了:它整条就是"开合开关",不该再带导航。
* 把 <a> 放进来只会让"点空白=开合、点文字=跳转"这种
* 半吊子分工回来,反而更难用。
*
* 所以这里纯文本 + 箭头,箭头用 CSS 三角(不依赖字体图标)。 */
echo '<summary class="yang-cats-trigger">';
echo '<span class="yang-cats-label">'
. ($current_name !== ''
? $this->name_html($current_name)
: esc_html__('选择分类', 'kratos'))
. '</span>';
echo '<span class="yang-cats-caret" aria-hidden="true"></span>';
echo '</summary>';
echo '<div class="yang-cats-panel" id="' . esc_attr($panel_id) . '">';
echo '<ul class="yang-cats-list">';
foreach ($tree as $node) {
$root = $node['cat'];
@@ -1387,7 +1458,7 @@ class widget_categories extends WP_Widget
$kids = $node['children'];
$has_kids = ($show_children && count($kids) > 0);
$root_cls = 'yang-cat-item';
$root_cls = 'yang-cats-item';
if ($has_kids) {
$root_cls .= ' has-children';
}
@@ -1400,58 +1471,36 @@ class widget_categories extends WP_Widget
}
echo '<li class="' . esc_attr($root_cls) . '">';
echo '<a class="yang-cats-link" href="' . esc_url(get_category_link($rid)) . '">'
. $this->name_html($root->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cats-count">' . (int) $root->count . '</span>';
}
/* 子分类:只有用户显式勾了「显示子分类」才输出。
*
* 🔴 这里**不再用 <details> 嵌套**(3.2.54 是那样)。原因:
* 面板本身就是浮层,展开状态只由触发条一个开关管;
* 再套一层折叠,用户点开面板后仍要再点一次才能看见子级,
* 「点击后把所有的一级分类拉出来」这个诉求就没满足。
* 子级直接平铺在父级下面、缩进显示 —— 一目了然,少一次点击。 */
if ($has_kids) {
/* 有子级:<details> 原生折叠。
*
* 🔴 为什么父级链接可以放在 <summary> 里(这不是偷懒):
* HTML 规范规定,`<summary>` 的"激活即折叠"行为只在该
* 点击目标**不是** interactive content 时才触发,而 `<a>`
* 本身属于 interactive content。所以点名字是**纯导航**、
* 不会顺带展开;点箭头/数字/行内空白才折叠。
* 两个动作各归各的,不需要一行 JS。
* (若哪天有人把 `<a>` 换成 `<span onclick>`,这条就失效了,
* 所以这里写清楚原因,避免后人改错。)
*
* 🔴 为什么用 <details> 而不是 JS 手风琴:
* · 不依赖 jQuery/Bootstrap 版本,也不跟主题其它脚本抢事件;
* · 键盘 Tab + 回车天然可用,读屏软件正确朗读展开状态;
* · 关掉 JS 仍能展开(分类导航是基础功能,不该依赖脚本)。 */
echo '<details class="yang-cat-group">';
echo '<summary class="yang-cat-summary">';
echo '<span class="yang-cat-caret" aria-hidden="true"></span>';
echo '<a class="yang-cat-link" href="' . esc_url(get_category_link($rid)) . '">'
. $this->name_html($root->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cat-count">' . (int) $root->count . '</span>';
}
echo '</summary>';
echo '<ul class="yang-cat-children">';
echo '<ul class="yang-cats-children">';
foreach ($kids as $kid) {
$kid_id = (int) $kid->term_id;
$kid_cls = 'yang-cat-item';
$kid_cls = 'yang-cats-item';
if ($current_id === $kid_id) {
$kid_cls .= ' current-cat';
}
echo '<li class="' . esc_attr($kid_cls) . '">';
echo '<a class="yang-cat-link" href="' . esc_url(get_category_link($kid_id)) . '">'
echo '<a class="yang-cats-link" href="' . esc_url(get_category_link($kid_id)) . '">'
. $this->name_html($kid->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cat-count">' . (int) $kid->count . '</span>';
echo '<span class="yang-cats-count">' . (int) $kid->count . '</span>';
}
echo '</li>';
}
echo '</ul>';
echo '</details>';
} else {
// 无子级:普通一行
echo '<a class="yang-cat-link" href="' . esc_url(get_category_link($rid)) . '">'
. $this->name_html($root->name) . '</a>';
if ($show_count) {
echo '<span class="yang-cat-count">' . (int) $root->count . '</span>';
}
}
echo '</li>';
@@ -1459,6 +1508,49 @@ class widget_categories extends WP_Widget
echo '</ul>';
echo '</div>';
echo '</details>';
echo '</div>';
/* ── 渐进增强:点面板外部 / 按 Esc 收起 ──────────────────────────
*
* 🔴 为什么这段必须写在挂件里而不是 kratos.js:
* 面板是 `<details>`,只有挂件知道它在哪;写进主脚本就得靠类名
* 全局查询,一旦页面上没有这个挂件就是一次空查询 + 一个常驻事件
* 监听。挂在挂件输出处,没挂件就没这段代码。
*
* 🔴 为什么用 `once` 标志:挂件可以有多个实例,每实例输出一段脚本,
* 全局 document 上的监听会被装 N 次。用 window 上的标志位保证
* 只装一次(监听器内部用 `closest('.w-cats')` 定位到自己)。
*
* 🔴 为什么不用内联 onclick 属性:CSP 里 `unsafe-inline` 一收紧
* 就全废。这里用 addEventListener,属性里只有 data-* 标记。
*
* 🔴 无 JS 时行为:点触发条展开、点分类跳转都正常,只是
* "点外部不自动收起"。功能不缺,所以不写成必要条件。 */
echo '<script>'
. '(function(){'
/* 已经装过一次就直接退出(多实例场景) */
. 'if(window.__yangCatsBound){return;}window.__yangCatsBound=1;'
/* 点外部关闭:只处理"点到的不是这个 details 内部"的情况 */
. 'document.addEventListener("click",function(e){'
. 'var t=e.target;'
. 'var list=document.querySelectorAll(".yang-cats-drop[open]");'
. 'for(var i=0;i<list.length;i++){'
. 'if(!list[i].contains(t)){list[i].removeAttribute("open");}'
. '}'
. '},false);'
/* Esc 关闭:键盘用户 */
. 'document.addEventListener("keydown",function(e){'
. 'if(e.key!=="Escape"&&e.keyCode!==27){return;}'
. 'var list=document.querySelectorAll(".yang-cats-drop[open]");'
. 'for(var i=0;i<list.length;i++){'
. 'list[i].removeAttribute("open");'
. 'var s=list[i].querySelector(".yang-cats-trigger");'
. 'if(s&&s.focus){s.focus();}'
. '}'
. '},false);'
. '})();'
. '</script>';
}
public function update($new_instance, $old_instance)
@@ -1501,7 +1593,8 @@ class widget_categories extends WP_Widget
$title = isset($instance['title']) ? (string) $instance['title'] : '';
$show_count = $this->pick($instance, 'count', true);
$hide_empty = $this->pick($instance, 'hide_empty', true);
$show_children = $this->pick($instance, 'children', true);
// 与 widget() 保持同一默认值(理由见 widget() 里的注释)
$show_children = $this->pick($instance, 'children', false);
$orderby = $this->pick($instance, 'orderby', 'name');
$order = $this->pick($instance, 'order', 'ASC');
if ($orderby !== 'count') {
@@ -1520,8 +1613,8 @@ class widget_categories extends WP_Widget
<p>
<input class="checkbox" type="checkbox" id="<?php echo esc_attr($this->get_field_id('children')); ?>" name="<?php echo esc_attr($this->get_field_name('children')); ?>" <?php checked($show_children); ?>>
<label for="<?php echo esc_attr($this->get_field_id('children')); ?>"><?php _e('显示子分类(父级可展开)', 'kratos'); ?></label>
<span class="description"><?php _e('关掉则只列父分类。', 'kratos'); ?></span>
<label for="<?php echo esc_attr($this->get_field_id('children')); ?>"><?php _e('展开时同时列出子分类', 'kratos'); ?></label>
<span class="description"><?php _e('默认只列一级分类。勾上后,有子分类的父级下面会缩进列出它的子分类。', 'kratos'); ?></span>
</p>
<p>