新增宽表自定义横向滚动条

This commit is contained in:
汤学会 2026-06-15 13:12:49 +08:00
parent ac18942a4b
commit eb472ad455
3 changed files with 417 additions and 3 deletions

View File

@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "TableControls.vue"), "utf8");
const styles = readFileSync(join(__dirname, "../styles/main.css"), "utf8");
describe("TableControls wide-table scroll dock", () => {
it("creates an app-level horizontal scroll dock for wide mode instead of relying on native scrollbars", () => {
assert.match(source, /function ensureWideTableScrollDock\(/);
assert.match(source, /className = "table-scroll-dock"/);
assert.match(source, /aria-label", "宽表横向滚动"/);
assert.match(source, /table-scroll-dock-track/);
assert.match(source, /table-scroll-dock-thumb/);
assert.match(source, /table-scroll-dock-arrow/);
});
it("keeps the scroll dock synchronized with the table wrapper scroll position", () => {
assert.match(source, /function syncWideTableScrollDock\(/);
assert.match(source, /wideScrollWrap\.scrollLeft/);
assert.match(source, /wideScrollWrap\.scrollWidth - wideScrollWrap\.clientWidth/);
assert.match(source, /wideScrollThumb\.style\.transform/);
assert.match(source, /wideScrollThumb\.style\.width/);
});
it("supports dragging the custom thumb and using left-right step arrows", () => {
assert.match(source, /pointerdown/);
assert.match(source, /setPointerCapture/);
assert.match(source, /function nudgeWideTableScrollDock\(/);
assert.match(source, /wideScrollWrap\.scrollBy/);
});
it("styles the scroll dock as the accepted visible low-friction control", () => {
assert.match(styles, /body \.table-scroll-dock/);
assert.match(styles, /body \.table-scroll-dock-track/);
assert.match(styles, /body \.table-scroll-dock-thumb/);
assert.match(styles, /body \.table-scroll-dock-arrow/);
assert.match(styles, /body \.table-view-wide \+ \.table-scroll-dock/);
});
});

View File

@ -78,6 +78,13 @@ let headerObserver = null;
let enhanceFrame = 0; let enhanceFrame = 0;
let modeFrame = 0; let modeFrame = 0;
let isEnhancing = false; let isEnhancing = false;
let wideScrollDock = null;
let wideScrollWrap = null;
let wideScrollTrack = null;
let wideScrollThumb = null;
let wideScrollResizeObserver = null;
let wideScrollCleanupHandlers = [];
let wideScrollFrame = 0;
function normalizeLabel(value) { function normalizeLabel(value) {
return String(value || "") return String(value || "")
@ -131,6 +138,212 @@ function clearTableViewClasses(table) {
delete table.dataset.tableViewMode; delete table.dataset.tableViewMode;
} }
function removeWideTableScrollDock() {
wideScrollCleanupHandlers.forEach((cleanup) => cleanup());
wideScrollCleanupHandlers = [];
if (wideScrollResizeObserver) {
wideScrollResizeObserver.disconnect();
wideScrollResizeObserver = null;
}
if (wideScrollFrame) {
window.cancelAnimationFrame(wideScrollFrame);
wideScrollFrame = 0;
}
wideScrollDock?.remove();
wideScrollDock = null;
wideScrollWrap = null;
wideScrollTrack = null;
wideScrollThumb = null;
}
function getWideScrollMetrics() {
if (!wideScrollWrap || !wideScrollTrack) {
return null;
}
const maxScroll = Math.max(0, wideScrollWrap.scrollWidth - wideScrollWrap.clientWidth);
const trackWidth = wideScrollTrack.clientWidth || 0;
const thumbWidth = maxScroll > 0
? Math.max(72, Math.min(trackWidth, (wideScrollWrap.clientWidth / wideScrollWrap.scrollWidth) * trackWidth))
: trackWidth;
const thumbTravel = Math.max(0, trackWidth - thumbWidth);
const progress = maxScroll > 0 ? wideScrollWrap.scrollLeft / maxScroll : 0;
return { maxScroll, trackWidth, thumbWidth, thumbTravel, progress };
}
function syncWideTableScrollDock() {
const metrics = getWideScrollMetrics();
if (!metrics || !wideScrollDock || !wideScrollThumb) {
return;
}
wideScrollDock.classList.toggle("table-scroll-dock-disabled", metrics.maxScroll <= 1);
wideScrollThumb.style.width = `${metrics.thumbWidth}px`;
wideScrollThumb.style.transform = `translateX(${metrics.thumbTravel * metrics.progress}px)`;
}
function scheduleWideTableScrollDockSync() {
if (wideScrollFrame) {
return;
}
wideScrollFrame = window.requestAnimationFrame(() => {
wideScrollFrame = 0;
syncWideTableScrollDock();
});
}
function nudgeWideTableScrollDock(direction) {
if (!wideScrollWrap) {
return;
}
wideScrollWrap.scrollBy({
left: direction * Math.max(180, wideScrollWrap.clientWidth * 0.42),
behavior: "smooth"
});
}
function createWideTableScrollDock() {
const dock = document.createElement("div");
dock.className = "table-scroll-dock";
dock.setAttribute("aria-label", "宽表横向滚动");
const leftArrow = document.createElement("button");
leftArrow.type = "button";
leftArrow.className = "table-scroll-dock-arrow table-scroll-dock-arrow-left";
leftArrow.setAttribute("aria-label", "向左滚动宽表");
leftArrow.innerHTML = '<span aria-hidden="true"></span>';
const track = document.createElement("div");
track.className = "table-scroll-dock-track";
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = "table-scroll-dock-thumb";
thumb.setAttribute("aria-label", "拖动查看宽表隐藏字段");
thumb.innerHTML = '<span aria-hidden="true"></span>';
const rightArrow = document.createElement("button");
rightArrow.type = "button";
rightArrow.className = "table-scroll-dock-arrow table-scroll-dock-arrow-right";
rightArrow.setAttribute("aria-label", "向右滚动宽表");
rightArrow.innerHTML = '<span aria-hidden="true"></span>';
track.appendChild(thumb);
dock.append(leftArrow, track, rightArrow);
wideScrollTrack = track;
wideScrollThumb = thumb;
const handleTrackPointerDown = (event) => {
if (!wideScrollWrap || event.target === thumb) {
return;
}
const metrics = getWideScrollMetrics();
if (!metrics?.maxScroll || !metrics.thumbTravel) {
return;
}
const rect = track.getBoundingClientRect();
const targetProgress = Math.min(1, Math.max(0, (event.clientX - rect.left - metrics.thumbWidth / 2) / metrics.thumbTravel));
wideScrollWrap.scrollLeft = targetProgress * metrics.maxScroll;
syncWideTableScrollDock();
};
const handleThumbPointerDown = (event) => {
const metrics = getWideScrollMetrics();
if (!wideScrollWrap || !metrics?.maxScroll || !metrics.thumbTravel) {
return;
}
event.preventDefault();
thumb.setPointerCapture?.(event.pointerId);
dock.classList.add("table-scroll-dock-dragging");
const startX = event.clientX;
const startScrollLeft = wideScrollWrap.scrollLeft;
const handlePointerMove = (moveEvent) => {
const delta = moveEvent.clientX - startX;
wideScrollWrap.scrollLeft = startScrollLeft + (delta / metrics.thumbTravel) * metrics.maxScroll;
syncWideTableScrollDock();
};
const handlePointerUp = () => {
dock.classList.remove("table-scroll-dock-dragging");
thumb.removeEventListener("pointermove", handlePointerMove);
thumb.removeEventListener("pointerup", handlePointerUp);
thumb.removeEventListener("pointercancel", handlePointerUp);
};
thumb.addEventListener("pointermove", handlePointerMove);
thumb.addEventListener("pointerup", handlePointerUp);
thumb.addEventListener("pointercancel", handlePointerUp);
};
const handleKeyDown = (event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
nudgeWideTableScrollDock(-1);
}
if (event.key === "ArrowRight") {
event.preventDefault();
nudgeWideTableScrollDock(1);
}
};
const handleLeftClick = () => nudgeWideTableScrollDock(-1);
const handleRightClick = () => nudgeWideTableScrollDock(1);
leftArrow.addEventListener("click", handleLeftClick);
rightArrow.addEventListener("click", handleRightClick);
track.addEventListener("pointerdown", handleTrackPointerDown);
thumb.addEventListener("pointerdown", handleThumbPointerDown);
thumb.addEventListener("keydown", handleKeyDown);
wideScrollCleanupHandlers.push(() => leftArrow.removeEventListener("click", handleLeftClick));
wideScrollCleanupHandlers.push(() => rightArrow.removeEventListener("click", handleRightClick));
wideScrollCleanupHandlers.push(() => track.removeEventListener("pointerdown", handleTrackPointerDown));
wideScrollCleanupHandlers.push(() => thumb.removeEventListener("pointerdown", handleThumbPointerDown));
wideScrollCleanupHandlers.push(() => thumb.removeEventListener("keydown", handleKeyDown));
return dock;
}
function ensureWideTableScrollDock(wrap) {
if (!wrap || tableMode.value !== "wide") {
removeWideTableScrollDock();
return;
}
if (wideScrollWrap !== wrap) {
removeWideTableScrollDock();
}
wideScrollWrap = wrap;
if (!wideScrollDock) {
wideScrollDock = createWideTableScrollDock();
wrap.insertAdjacentElement("afterend", wideScrollDock);
const handleWrapScroll = () => scheduleWideTableScrollDockSync();
wideScrollWrap.addEventListener("scroll", handleWrapScroll, { passive: true });
window.addEventListener("resize", scheduleWideTableScrollDockSync);
if (typeof ResizeObserver !== "undefined") {
wideScrollResizeObserver = new ResizeObserver(scheduleWideTableScrollDockSync);
wideScrollResizeObserver.observe(wideScrollWrap);
const table = wideScrollWrap.querySelector("table");
if (table) {
wideScrollResizeObserver.observe(table);
}
}
wideScrollCleanupHandlers.push(() => wideScrollWrap?.removeEventListener("scroll", handleWrapScroll));
wideScrollCleanupHandlers.push(() => window.removeEventListener("resize", scheduleWideTableScrollDockSync));
} else if (wideScrollDock.previousElementSibling !== wrap) {
wrap.insertAdjacentElement("afterend", wideScrollDock);
}
scheduleWideTableScrollDockSync();
}
function disconnectModeObserver() { function disconnectModeObserver() {
if (modeTableObserver) { if (modeTableObserver) {
modeTableObserver.disconnect(); modeTableObserver.disconnect();
@ -320,6 +533,11 @@ function applyTableViewMode(table = findTableAfterControl()) {
refreshCellTooltips(table); refreshCellTooltips(table);
bindCellTooltip(table); bindCellTooltip(table);
observeTableForViewMode(table); observeTableForViewMode(table);
if (isWide) {
ensureWideTableScrollDock(wrap);
} else {
removeWideTableScrollDock();
}
} }
function scheduleApplyTableViewMode() { function scheduleApplyTableViewMode() {
@ -573,6 +791,7 @@ onBeforeUnmount(() => {
modeFrame = 0; modeFrame = 0;
} }
clearTableViewClasses(modeTable); clearTableViewClasses(modeTable);
removeWideTableScrollDock();
disconnectModeObserver(); disconnectModeObserver();
clearTooltipListeners(); clearTooltipListeners();
clearEnhancedHeaders(); clearEnhancedHeaders();

View File

@ -11588,12 +11588,12 @@ body .table-view-wide {
overflow-x: scroll !important; overflow-x: scroll !important;
overflow-y: hidden !important; overflow-y: hidden !important;
scrollbar-gutter: stable both-edges; scrollbar-gutter: stable both-edges;
scrollbar-width: thin; scrollbar-width: none;
scrollbar-color: rgba(100, 116, 139, 0.5) rgba(226, 232, 240, 0.72);
} }
body .table-view-wide::-webkit-scrollbar { body .table-view-wide::-webkit-scrollbar {
height: 12px; width: 0;
height: 0;
} }
body .table-view-wide::-webkit-scrollbar-track { body .table-view-wide::-webkit-scrollbar-track {
@ -11611,6 +11611,158 @@ body .table-view-wide::-webkit-scrollbar-thumb:hover {
background: rgba(71, 85, 105, 0.72); background: rgba(71, 85, 105, 0.72);
} }
body .table-view-wide + .table-scroll-dock,
body .table-scroll-dock {
position: relative;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
gap: 10px;
align-items: center;
min-height: 56px;
margin-top: -1px;
padding: 10px 16px;
border: 1px solid rgba(205, 218, 233, 0.92);
border-radius: 0 0 18px 18px;
background:
linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 14px 26px rgba(15, 23, 42, 0.06);
box-sizing: border-box;
}
body .table-view-wide + .table-scroll-dock::before,
body .table-view-wide + .table-scroll-dock::after,
body .table-scroll-dock::before,
body .table-scroll-dock::after {
position: absolute;
top: 1px;
bottom: 1px;
z-index: 1;
width: 58px;
pointer-events: none;
content: "";
}
body .table-view-wide + .table-scroll-dock::before,
body .table-scroll-dock::before {
left: 0;
background: linear-gradient(90deg, rgba(248, 251, 255, 0.98), rgba(248, 251, 255, 0));
}
body .table-view-wide + .table-scroll-dock::after,
body .table-scroll-dock::after {
right: 0;
background: linear-gradient(270deg, rgba(248, 251, 255, 0.98), rgba(248, 251, 255, 0));
}
body .table-scroll-dock-arrow {
position: relative;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(191, 206, 224, 0.9);
border-radius: 12px;
background:
linear-gradient(180deg, #ffffff 0%, #eef5fc 100%);
color: #64748b;
font: inherit;
font-size: 30px;
font-weight: 500;
line-height: 1;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
0 6px 14px rgba(15, 23, 42, 0.06);
cursor: pointer;
transition:
border-color 0.16s ease,
color 0.16s ease,
transform 0.16s ease,
box-shadow 0.16s ease;
}
body .table-scroll-dock-arrow span {
transform: translateY(-2px);
}
body .table-scroll-dock-arrow:hover {
border-color: rgba(64, 158, 255, 0.48);
color: #1677ff;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
0 8px 18px rgba(22, 119, 255, 0.14);
transform: translateY(-1px);
}
body .table-scroll-dock-track {
position: relative;
z-index: 2;
height: 22px;
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(180deg, rgba(226, 234, 243, 0.98), rgba(211, 222, 235, 0.98));
box-shadow:
inset 0 1px 2px rgba(15, 23, 42, 0.12),
inset 0 -1px 0 rgba(255, 255, 255, 0.82);
cursor: pointer;
}
body .table-scroll-dock-thumb {
position: absolute;
top: 3px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
height: 16px;
border: 0;
border-radius: 999px;
background:
linear-gradient(90deg, #8fc4ff 0%, #409eff 35%, #1677ff 100%);
box-shadow:
0 7px 16px rgba(22, 119, 255, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
cursor: grab;
touch-action: none;
transition:
box-shadow 0.16s ease,
filter 0.16s ease;
}
body .table-scroll-dock-thumb span {
width: 28px;
height: 10px;
background:
linear-gradient(90deg, transparent 0 5px, rgba(255, 255, 255, 0.68) 5px 7px, transparent 7px 11px, rgba(255, 255, 255, 0.68) 11px 13px, transparent 13px 17px, rgba(255, 255, 255, 0.68) 17px 19px, transparent 19px);
}
body .table-scroll-dock-thumb:hover,
body .table-scroll-dock-dragging .table-scroll-dock-thumb {
box-shadow:
0 10px 24px rgba(22, 119, 255, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
filter: saturate(1.08);
}
body .table-scroll-dock-dragging .table-scroll-dock-thumb {
cursor: grabbing;
}
body .table-scroll-dock-disabled {
display: none;
}
@media (prefers-reduced-motion: reduce) {
body .table-scroll-dock-arrow,
body .table-scroll-dock-thumb {
transition: none;
}
}
body .table-view-wide .data-table, body .table-view-wide .data-table,
body .table-view-wide .compact-table, body .table-view-wide .compact-table,
body .table-view-wide .drawer-data-table, body .table-view-wide .drawer-data-table,