diff --git a/frontend/src/components/TableControls.test.js b/frontend/src/components/TableControls.test.js new file mode 100644 index 0000000..8c95567 --- /dev/null +++ b/frontend/src/components/TableControls.test.js @@ -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/); + }); +}); diff --git a/frontend/src/components/TableControls.vue b/frontend/src/components/TableControls.vue index 426cd3c..bbb1508 100644 --- a/frontend/src/components/TableControls.vue +++ b/frontend/src/components/TableControls.vue @@ -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 = ''; + + 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 = ''; + + 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 = ''; + + 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(); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 76697db..6e49bc5 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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,