网页端下拉框改成自绘浮层(照 FeedLite 的做法)(#85)

用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统里面的
下拉框到底是怎么做的?按它的做法做,它也有主题颜色,我们这个也有主题颜色,
就按照一样的做法去做。」

看了 FeedLite(~/code/FeedLite)的实现,关键结论:
  **原生 <select> 收起时能改样式,但展开的选项列表由浏览器绘制,CSS 完全改不动**
  (只能改 option 底色,各平台还不统一)⇒ 想好看只有自绘这条路。

照它的做法移植:

① CSS(assets/css/app.css)
   .sel-native  原生 select 视觉隐藏(**不能 display:none**,那样不参与提交)
   .sel         外层 inline-flex
   .sel-btn     触发按钮 —— 外观与普通输入框完全一致(40 高、同圆角同边框)
   .sel-caret   自绘箭头(CSS mask + 内联 SVG),**打开时转 180° 并变主题色**
   .sel-panel   浮层,position:fixed 挂 body
   .sel-item    列表项:34 高、圆角 7
   .sel-item.on 选中项:深一档主题底色 + 主题色文字 + 右侧对勾

② 主题色三档浅色(照 FeedLite 的比例)
   helpers.php 新增 fb_color_tint()(把 hex 往白色混)。
   layout.php 在 <body> 的 style 里输出:
     --brand-tint-1(混 91% 白)→ hover
     --brand-tint-2(混 80% 白)→ 选中项
     --brand-tint-3(混 72% 白)→ 选中项 hover
   ⚠️ 在 PHP 里算好,**不用 CSS 的 color-mix()** —— FeedLite 实测过:
      `color-mix(in srgb, var(--brand) 9%, #fff)` 在 Chrome 里解析成透明。

③ JS(assets/js/app.js 新增 fbBindSelects())
   遍历所有 select,在旁边生成按钮 + 浮层,原生 select 保留参与提交。
   · 派发原生 change → 兼容 onchange="this.form.submit()" 的内联写法
   · 浮层 fixed 定位(面板/浮层里大量 overflow:hidden,放旁边会被裁)
   · 下方空间不足且上方更宽裕时**向上翻**
   · 键盘:↑↓ / Home / End / Enter / Esc
   · 浮层 minWidth 跟随触发按钮 → "和输入框一样宽"
   · data-sel-enhanced 幂等标记

④ AJAX 路径也要增强
   task_panel.paint() 与 entry_popup.loadPanel() 里,紧跟 fbBootAntd() 之后
   调一次 window.fbBindSelects(document) —— 否则动态插入的 select 是裸的。

⚠️ app.js 整个是 IIFE 包裹的(结尾 `})();`),新函数必须插在闭包内部;
   直接追加到结尾会跑到全局作用域。

实测(Playwright + 站点真实 app.js/css):
  增强:data-sel-enhanced=1、原生 select 已隐藏、按钮文字「工作相关」✅
  尺寸:按钮 648 x 40,行宽 648 → 占满整行 ✅
  浮层:宽 648(与按钮同宽)✅ 6 项、选中项有对勾 ✅
  打开态:底色 rgb(250,242,238)=tint-1、文字 rgb(168,74,40)=brand、
          箭头 rotate 180° ✅
  选「个人事务」→ select.value=3、按钮文字同步、浮层关闭 ✅
This commit is contained in:
2026-11-05 20:23:16 +08:00
parent 47a0027ef7
commit 69c47c3c43
6 changed files with 414 additions and 1 deletions
+232
View File
@@ -2848,4 +2848,236 @@ window.fbHomeTaskDrop = function (row, wasStatus) {
bindNodeActs();
}
window.fbBindNodeActs = bindNodeActs;
/* ══════════════════════════════════════════════════════════════════════
#85 自绘下拉框(把原生 select 的展开列表换成自绘浮层)
══════════════════════════════════════════════════════════════════════
🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统
(FeedLite)里面的下拉框到底是怎么做的?按它的做法做。」
为什么必须自绘:原生 `<select>` 收起时能改样式,但**展开列表由浏览器绘制,
CSS 完全改不动**(只能改 option 底色),各平台样式还不统一。
但**不重写 HTML**:原生 select 留在 DOM 里参与表单提交,我们只在旁边
生成一个按钮 + 浮层。好处:
· 全站所有 select 自动获得新样式,不用逐个改模板;
· `onchange="this.form.submit()"` 这类内联逻辑照样工作(我们派发原生 change);
· 无 JS 时原生 select 仍可用(只是朴素),不会白屏。
⚠️ 浮层 `position: fixed` 挂 body —— 面板/浮层里大量 `overflow:hidden`,
放 select 旁边会被裁掉(FeedLite 踩过这个坑)。
⚠️ AJAX 换进来的内容(任务面板)要**再次调用** `fbBindSelects()`,
否则新 select 没有增强。已在 task_panel.paint / entry_popup.loadPanel 里接上。
══════════════════════════════════════════════════════════════════════ */
function fbBindSelects(root) {
var scope = root || document;
var sels = scope.querySelectorAll('select');
for (var i = 0; i < sels.length; i++) {
(function (sel) {
/* 幂等:已经增强过的跳过(AJAX 重画时会重复调用) */
if (sel.getAttribute('data-sel-enhanced') === '1') { return; }
/* 明确排除的(比如需要在原位置显示多行列表的) */
if (sel.hasAttribute('data-no-enhance')) { return; }
/* 多选 / 列表框不动 —— 那些本来就有自己的多行展示方式 */
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
/* 隐藏域也不要(有些地方用它传值) */
if (sel.style.display === 'none' || sel.hidden) { return; }
sel.setAttribute('data-sel-enhanced', '1');
sel.classList.add('sel-native');
var wrap = document.createElement('span');
wrap.className = 'sel';
sel.parentNode.insertBefore(wrap, sel);
wrap.appendChild(sel);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sel-btn';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
wrap.appendChild(btn);
var panel = document.createElement('div');
panel.className = 'sel-panel';
panel.hidden = true;
panel.setAttribute('role', 'listbox');
document.body.appendChild(panel);
var active = -1;
function curText() {
var o = sel.options[sel.selectedIndex];
return o ? o.textContent : '';
}
function syncBtn() {
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
btn.querySelector('.sel-txt').textContent = curText();
btn.disabled = sel.disabled;
/* 首项值为空 = 占位项(未分类 / 不循环),文字用次要色 */
btn.classList.toggle('is-placeholder',
sel.selectedIndex <= 0 && sel.options.length > 0 && sel.options[0].value === '');
}
function buildList() {
panel.innerHTML = '';
for (var k = 0; k < sel.options.length; k++) {
(function (idx) {
var o = sel.options[idx];
if (o.hidden) { return; }
var item = document.createElement('button');
item.type = 'button';
item.className = 'sel-item';
item.setAttribute('role', 'option');
var on = idx === sel.selectedIndex;
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
var label = document.createElement('span');
label.className = 'sel-item-txt';
label.textContent = o.textContent;
item.appendChild(label);
if (on) {
var ck = document.createElement('span');
ck.className = 'sel-item-ck';
ck.setAttribute('aria-hidden', 'true');
item.appendChild(ck);
}
item.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
pick(idx);
});
panel.appendChild(item);
})(k);
}
}
function pick(idx) {
if (idx < 0 || idx >= sel.options.length) { return; }
var changed = sel.selectedIndex !== idx;
sel.selectedIndex = idx;
syncBtn();
close();
/* 派发原生 change —— 兼容 onchange="this.form.submit()" 那种写法 */
if (changed) {
var ev = document.createEvent('HTMLEvents');
ev.initEvent('change', true, false);
sel.dispatchEvent(ev);
}
}
function open() {
if (btn.disabled) { return; }
buildList();
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-open');
/* 先量宽度再定位:panel 刚显示时 offsetWidth 才有值 */
position();
active = sel.selectedIndex;
highlight(active);
document.addEventListener('mousedown', outside, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
}
function close() {
if (panel.hidden) { return; }
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('is-open');
document.removeEventListener('mousedown', outside, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
}
function outside(e) {
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
}
/* 定位:fixed 挂 body,不服从任何祖先的 overflow。
优先向下展开;下方不够且上方更宽裕时向上翻。 */
function position() {
if (panel.hidden) { return; }
var r = btn.getBoundingClientRect();
var vw = window.innerWidth, vh = window.innerHeight;
var pw = Math.max(panel.offsetWidth, r.width);
var ph = panel.offsetHeight;
var margin = 8;
var left = r.left;
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
var below = vh - r.bottom - margin;
var above = r.top - margin;
var openUp = (ph > below) && (above > below);
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
if (top < margin) { top = margin; }
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
/* 与触发按钮同宽(用户要求"和输入框一样宽") */
panel.style.minWidth = Math.round(r.width) + 'px';
panel.classList.toggle('is-up', openUp);
}
function highlight(idx) {
var items = panel.querySelectorAll('.sel-item');
for (var k = 0; k < items.length; k++) {
items[k].classList.toggle('is-active', k === idx);
}
if (idx >= 0 && items[idx]) {
var it = items[idx];
var top = it.offsetTop, bot = top + it.offsetHeight;
if (top < panel.scrollTop) { panel.scrollTop = top; }
else if (bot > panel.scrollTop + panel.clientHeight) {
panel.scrollTop = bot - panel.clientHeight;
}
}
}
function onKey(e) {
var items = panel.querySelectorAll('.sel-item');
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
active = Math.min(items.length - 1, active + 1); highlight(active);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
active = Math.max(0, active - 1); highlight(active);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (active >= 0) { pick(active); }
} else if (e.key === 'Home') {
e.preventDefault(); active = 0; highlight(active);
} else if (e.key === 'End') {
e.preventDefault(); active = items.length - 1; highlight(active);
}
}
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
if (panel.hidden) { open(); } else { close(); }
});
/* 原生 select 被程序改值时,按钮文字要跟着变 */
sel.addEventListener('change', syncBtn);
syncBtn();
})(sels[i]);
}
}
/* 对外暴露:AJAX 换进来的内容要再调一次 */
window.fbBindSelects = fbBindSelects;
/* #85:首屏自动增强所有 select */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { fbBindSelects(document); });
} else {
fbBindSelects(document);
}
})();