新增宽表自定义横向滚动条
This commit is contained in:
parent
ac18942a4b
commit
eb472ad455
43
frontend/src/components/TableControls.test.js
Normal file
43
frontend/src/components/TableControls.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
@ -78,6 +78,13 @@ let headerObserver = null;
|
||||
let enhanceFrame = 0;
|
||||
let modeFrame = 0;
|
||||
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) {
|
||||
return String(value || "")
|
||||
@ -131,6 +138,212 @@ function clearTableViewClasses(table) {
|
||||
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() {
|
||||
if (modeTableObserver) {
|
||||
modeTableObserver.disconnect();
|
||||
@ -320,6 +533,11 @@ function applyTableViewMode(table = findTableAfterControl()) {
|
||||
refreshCellTooltips(table);
|
||||
bindCellTooltip(table);
|
||||
observeTableForViewMode(table);
|
||||
if (isWide) {
|
||||
ensureWideTableScrollDock(wrap);
|
||||
} else {
|
||||
removeWideTableScrollDock();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleApplyTableViewMode() {
|
||||
@ -573,6 +791,7 @@ onBeforeUnmount(() => {
|
||||
modeFrame = 0;
|
||||
}
|
||||
clearTableViewClasses(modeTable);
|
||||
removeWideTableScrollDock();
|
||||
disconnectModeObserver();
|
||||
clearTooltipListeners();
|
||||
clearEnhancedHeaders();
|
||||
|
||||
@ -11588,12 +11588,12 @@ body .table-view-wide {
|
||||
overflow-x: scroll !important;
|
||||
overflow-y: hidden !important;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 116, 139, 0.5) rgba(226, 232, 240, 0.72);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
body .table-view-wide::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 .compact-table,
|
||||
body .table-view-wide .drawer-data-table,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user