diff --git a/components/blocks/ReferencesSlider.test.tsx b/components/blocks/ReferencesSlider.test.tsx new file mode 100644 index 000000000..52937bc47 --- /dev/null +++ b/components/blocks/ReferencesSlider.test.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ReferencesSlider, Reference } from './ReferencesSlider'; + +// Mock next/link to render standard tag +vi.mock('next/link', () => ({ + default: vi.fn(({ children, href, onClick, ...props }: any) => { + return ( + + {children} + + ); + }) +})); + +// Mock next/image to render standard img tag +vi.mock('next/image', () => ({ + default: (props: any) => { + return ; + } +})); + +describe('ReferencesSlider TDD', () => { + const mockReferences: Reference[] = [ + { + id: '1', + title: 'Referenz Projekt Eins', + slug: 'referenz-projekt-eins', + category: 'Kabeltiefbau', + }, + { + id: '2', + title: 'Referenz Projekt Zwei', + slug: 'referenz-projekt-zwei', + category: 'Bohrtechnik', + } + ]; + + it('renders reference tiles correctly', () => { + render(); + + expect(screen.getByText('Referenz Projekt Eins')).toBeTruthy(); + expect(screen.getByText('Referenz Projekt Zwei')).toBeTruthy(); + }); + + it('wraps each tile in a link to /referenzen/[slug]', () => { + render(); + + const links = screen.getAllByTestId('reference-tile-link'); + expect(links).toHaveLength(2); + expect(links[0].getAttribute('href')).toBe('/referenzen/referenz-projekt-eins'); + expect(links[1].getAttribute('href')).toBe('/referenzen/referenz-projekt-zwei'); + }); + + it('allows navigation on simple click (no drag)', () => { + render(); + + const firstLink = screen.getAllByTestId('reference-tile-link')[0]; + + // Simulate simple click sequence + fireEvent.mouseDown(firstLink, { pageX: 100, clientX: 100 }); + fireEvent.mouseUp(firstLink); + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + firstLink.dispatchEvent(clickEvent); + + expect(clickEvent.defaultPrevented).toBe(false); + }); + + it('prevents navigation if mouse was dragged', () => { + render(); + + const firstLink = screen.getAllByTestId('reference-tile-link')[0]; + + // Simulate drag sequence + fireEvent.mouseDown(firstLink, { pageX: 100, clientX: 100 }); + // Simulate mouse move of 20px + fireEvent.mouseMove(firstLink, { pageX: 120, clientX: 120 }); + fireEvent.mouseUp(firstLink); + + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + firstLink.dispatchEvent(clickEvent); + + expect(clickEvent.defaultPrevented).toBe(true); + }); +}); + diff --git a/components/blocks/ReferencesSlider.tsx b/components/blocks/ReferencesSlider.tsx index 02aea6248..7ec4757a6 100644 --- a/components/blocks/ReferencesSlider.tsx +++ b/components/blocks/ReferencesSlider.tsx @@ -37,9 +37,11 @@ export function ReferencesSlider(props: ReferencesSliderProps) { const { data } = props; const references = props.references || []; const containerRef = React.useRef(null); + const dragDistanceRef = React.useRef(0); + const startXRef = React.useRef(0); + const scrollLeftRef = React.useRef(0); + const isDraggingRef = React.useRef(false); const [isDragging, setIsDragging] = React.useState(false); - const [startX, setStartX] = React.useState(0); - const [scrollLeft, setScrollLeft] = React.useState(0); const badge = props.badge || data?.badge || 'Ausgewählte Projekte'; const title = props.title || data?.title || 'Referenzen & Erfolge'; @@ -60,24 +62,36 @@ export function ReferencesSlider(props: ReferencesSliderProps) { const onMouseDown = (e: React.MouseEvent) => { if (!containerRef.current) return; setIsDragging(true); - setStartX(e.pageX - containerRef.current.offsetLeft); - setScrollLeft(containerRef.current.scrollLeft); + isDraggingRef.current = true; + const startXVal = e.clientX - (containerRef.current.offsetLeft || 0); + startXRef.current = startXVal; + scrollLeftRef.current = containerRef.current.scrollLeft || 0; + dragDistanceRef.current = 0; }; const onMouseLeave = () => { setIsDragging(false); + isDraggingRef.current = false; }; const onMouseUp = () => { setIsDragging(false); + isDraggingRef.current = false; }; const onMouseMove = (e: React.MouseEvent) => { - if (!isDragging || !containerRef.current) return; + if (!isDraggingRef.current || !containerRef.current) return; e.preventDefault(); - const x = e.pageX - containerRef.current.offsetLeft; - const walk = (x - startX) * 2; // Scroll-fast - containerRef.current.scrollLeft = scrollLeft - walk; + const x = e.clientX - (containerRef.current.offsetLeft || 0); + const walk = (x - startXRef.current) * 2; // Scroll-fast + containerRef.current.scrollLeft = scrollLeftRef.current - walk; + dragDistanceRef.current = Math.abs(x - startXRef.current); + }; + + const handleTileClick = (e: React.MouseEvent) => { + if (dragDistanceRef.current > 10) { + e.preventDefault(); + } }; return ( @@ -117,7 +131,12 @@ export function ReferencesSlider(props: ReferencesSliderProps) { transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }} className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto" > -
+ {ref.title}

{ref.title}

- + ); })} diff --git a/package.json b/package.json index ad122aead..e591face5 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "@tailwindcss/cli": "^4.1.18", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/node": "^22.19.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08827915e..223be63f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.4)) '@types/recharts': specifier: ^2.0.1 - version: 2.0.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) + version: 2.0.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) axios: specifier: ^1.13.5 version: 1.14.0(debug@4.4.3) @@ -116,7 +116,7 @@ importers: version: 10.1.0(@types/react@19.2.14)(react@19.2.4) recharts: specifier: ^3.7.0 - version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -181,6 +181,12 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/geojson': specifier: ^7946.0.16 version: 7946.0.16 @@ -2936,6 +2942,25 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -2978,6 +3003,9 @@ packages: '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3510,6 +3538,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3523,6 +3555,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4336,6 +4371,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -5893,6 +5931,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + maath@0.10.8: resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} peerDependencies: @@ -6699,6 +6741,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.29.0: resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} engines: {node: '>=6'} @@ -6820,6 +6866,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-leaflet@4.2.1: resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} peerDependencies: @@ -7502,8 +7551,8 @@ packages: third-party-web@0.26.7: resolution: {integrity: sha512-buUzX4sXC4efFX6xg2bw6/eZsCUh8qQwSavC4D9HpONMFlRbcHhD8Je5qwYdCpViR6q0qla2wPP+t91a2vgolg==} - third-party-web@0.29.0: - resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==} + third-party-web@0.29.2: + resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} @@ -9809,7 +9858,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.0 + third-party-web: 0.29.2 '@pdf-lib/standard-fonts@1.0.0': dependencies: @@ -10822,6 +10871,27 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tootallnate/quickjs-emscripten@0.23.0': {} '@turbo/darwin-64@2.9.3': @@ -10853,6 +10923,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -10998,9 +11070,9 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/recharts@2.0.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)': + '@types/recharts@2.0.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1)': dependencies: - recharts: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) + recharts: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) transitivePeerDependencies: - '@types/react' - react @@ -11438,6 +11510,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} arg@5.0.2: {} @@ -11448,6 +11522,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -12317,6 +12395,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -14234,6 +14314,8 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + maath@0.10.8(@types/three@0.183.1)(three@0.183.2): dependencies: '@types/three': 0.183.1 @@ -15338,6 +15420,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.29.0: {} prismjs@1.30.0: {} @@ -15501,6 +15589,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -15553,7 +15643,7 @@ snapshots: real-require@0.2.0: {} - recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1): + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) clsx: 2.1.1 @@ -15563,7 +15653,7 @@ snapshots: immer: 10.2.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-is: 16.13.1 + react-is: 17.0.2 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -16409,7 +16499,7 @@ snapshots: third-party-web@0.26.7: {} - third-party-web@0.29.0: {} + third-party-web@0.29.2: {} thread-stream@4.0.0: dependencies: