diff --git a/src/app/App.module.css b/src/app/App.module.css index 689bae7..9c539f6 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -1,4 +1,7 @@ .appRoot { + --app-track-padding-top: clamp(72px, 14svh, 121px); + --app-track-padding-bottom: clamp(16px, 3svh, var(--space-24)); + --app-panel-height: calc(100svh - var(--app-track-padding-top) - var(--app-track-padding-bottom)); width: 100vw; min-height: 100svh; overflow: hidden; @@ -36,7 +39,7 @@ .horizontalScroller { width: 100vw; - height: 100vh; + height: 100svh; overflow-x: auto; overflow-y: hidden; display: flex; @@ -58,12 +61,15 @@ width: max-content; height: 100%; min-height: 100%; - padding: 121px var(--space-24) var(--space-24) var(--space-24); + padding: var(--app-track-padding-top) var(--space-24) var(--app-track-padding-bottom) + var(--space-24); } /* Ensure sections match the panel styling */ .horizontalScroller :global(.panel) { width: calc(100vw - calc(var(--space-24) * 2)); + height: var(--app-panel-height); + min-height: var(--app-panel-height); flex-shrink: 0; scroll-snap-align: center; border-radius: var(--radius-card); diff --git a/src/app/components/EventCard.module.css b/src/app/components/EventCard.module.css index 710b789..a11e6a3 100644 --- a/src/app/components/EventCard.module.css +++ b/src/app/components/EventCard.module.css @@ -1,10 +1,10 @@ .eventCard { display: grid; - gap: var(--space-16); + gap: clamp(10px, 1.8svh, var(--space-16)); align-content: start; width: 100%; min-height: 0; - padding: 20px; + padding: clamp(16px, 2.4svh, 20px); appearance: none; border-radius: 28px; border: 1px solid rgba(126, 225, 218, 0.28); @@ -35,38 +35,38 @@ .cardHeader { display: grid; - gap: var(--space-8); + gap: clamp(6px, 1svh, var(--space-8)); } .cardTitle { margin: 0; color: var(--c-text-light); - font: 600 24px/1.06 var(--font-display); + font: 600 clamp(20px, 3svh, 24px) / 1.06 var(--font-display); text-wrap: balance; } .cardDescription { margin: 0; color: rgba(249, 250, 250, 0.82); - font: 400 15px/1.35 var(--font-body); + font: 400 clamp(13px, 1.8svh, 15px) / 1.35 var(--font-body); } .metaList { display: grid; - gap: var(--space-8); + gap: clamp(6px, 1svh, var(--space-8)); margin: 0; } .metaRow { display: grid; - grid-template-columns: 58px minmax(0, 1fr); - gap: var(--space-16); + grid-template-columns: clamp(46px, 6svh, 58px) minmax(0, 1fr); + gap: clamp(10px, 1.6svh, var(--space-16)); align-items: start; } .metaRow dt { color: rgba(249, 250, 250, 0.54); - font: 600 12px/1.2 var(--font-body); + font: 600 clamp(11px, 1.5svh, 12px) / 1.2 var(--font-body); text-transform: uppercase; letter-spacing: 0.08em; } @@ -74,7 +74,7 @@ .metaRow dd { margin: 0; color: var(--c-text-light); - font: 500 14px/1.3 var(--font-body); + font: 500 clamp(13px, 1.7svh, 14px) / 1.3 var(--font-body); } @media (max-width: 720px) { @@ -86,3 +86,13 @@ font-size: 22px; } } + +@media (max-height: 760px) { + .eventCard { + gap: 10px; + } + + .metaRow { + grid-template-columns: 46px minmax(0, 1fr); + } +} diff --git a/src/app/components/EventRail.module.css b/src/app/components/EventRail.module.css index 8d8346d..acd06c2 100644 --- a/src/app/components/EventRail.module.css +++ b/src/app/components/EventRail.module.css @@ -2,22 +2,22 @@ display: grid; grid-template-rows: auto auto; align-content: start; - gap: 14px; + gap: clamp(10px, 1.8svh, 14px); overflow: hidden; padding-bottom: 12px; - min-height: 390px; + min-height: clamp(280px, 48svh, 390px); } .groupHeader { display: grid; gap: 0; - padding-right: 64px; + padding-right: clamp(24px, 4vw, 64px); } .groupTitle { margin: 0; color: var(--c-text-light); - font: 600 28px/1 var(--font-display); + font: 600 clamp(24px, 3.8svh, 28px) / 1 var(--font-display); text-transform: lowercase; } @@ -26,28 +26,28 @@ grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-16); - padding-right: 64px; + padding-right: clamp(24px, 4vw, 64px); } .emptyState { display: grid; place-items: center; - min-height: 276px; - padding-right: 64px; + min-height: clamp(200px, 32svh, 276px); + padding-right: clamp(24px, 4vw, 64px); color: rgba(249, 250, 250, 0.8); - font: 500 20px/1.4 var(--font-body); + font: 500 clamp(18px, 2.8svh, 20px) / 1.4 var(--font-body); letter-spacing: 0.01em; text-align: center; } .controlButton { - width: 48px; - height: 48px; + width: clamp(42px, 6.2svh, 48px); + height: clamp(42px, 6.2svh, 48px); border: 1px solid rgba(249, 250, 250, 0.28); border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: var(--c-text-light); - font: 700 22px/1 var(--font-display); + font: 700 clamp(20px, 3svh, 22px) / 1 var(--font-display); backdrop-filter: blur(var(--glass-blur-soft)); transition: background 180ms var(--ease-premium), @@ -75,7 +75,7 @@ --right-edge-fade: 72px; display: grid; grid-auto-flow: column; - grid-auto-columns: minmax(320px, 440px); + grid-auto-columns: clamp(260px, 32vw, 440px); gap: var(--space-24); overflow-x: auto; overflow-y: visible; @@ -85,6 +85,9 @@ scroll-behavior: smooth; scrollbar-width: none; align-items: stretch; + outline: none; + overscroll-behavior-x: contain; + touch-action: pan-x pinch-zoom; mask-image: linear-gradient( 90deg, transparent 0, @@ -101,6 +104,10 @@ ); } +.carousel:focus-visible { + box-shadow: inset 0 0 0 1px rgba(249, 250, 250, 0.28); +} + .carousel[data-can-scroll-back='false'] { --left-edge-fade: 0px; } @@ -113,8 +120,11 @@ display: none; } -.carousel:global(.is-dragging) { - cursor: grabbing; +@supports (-webkit-touch-callout: none) { + .carousel { + mask-image: none; + -webkit-mask-image: none; + } } @media (max-width: 1200px) { @@ -157,3 +167,17 @@ font-size: 18px; } } + +@media (max-height: 760px) { + .groupHeader, + .railShell, + .emptyState { + padding-right: 24px; + } + + .carousel { + --left-edge-fade: 36px; + --right-edge-fade: 36px; + grid-auto-columns: clamp(240px, 30vw, 360px); + } +} diff --git a/src/app/components/EventRail.tsx b/src/app/components/EventRail.tsx index 3e8e519..448cbaf 100644 --- a/src/app/components/EventRail.tsx +++ b/src/app/components/EventRail.tsx @@ -1,5 +1,7 @@ import { useEffect, useState, useRef } from 'react' +import type { KeyboardEvent } from 'react' import type { AppEvent } from '@/app/data/events' +import { useSafariHorizontalRailFallback } from '@/shared/hooks/useSafariHorizontalRailFallback' import { EventCard } from './EventCard' import styles from './EventRail.module.css' @@ -21,6 +23,8 @@ export function EventRail({ title, events, carouselLabel, onEventSelect }: Event const [canScrollForward, setCanScrollForward] = useState(false) const hasEvents = events.length > 0 + useSafariHorizontalRailFallback(railRef) + useEffect(() => { const rail = railRef.current if (!rail) return @@ -57,6 +61,34 @@ export function EventRail({ title, events, carouselLabel, onEventSelect }: Event }) } + const onRailKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowRight') { + event.preventDefault() + scrollRail('forward') + return + } + + if (event.key === 'ArrowLeft') { + event.preventDefault() + scrollRail('back') + return + } + + if (event.key === 'Home') { + event.preventDefault() + railRef.current?.scrollTo({ left: 0, behavior: 'smooth' }) + return + } + + if (event.key === 'End') { + const rail = railRef.current + if (!rail) return + + event.preventDefault() + rail.scrollTo({ left: rail.scrollWidth - rail.clientWidth, behavior: 'smooth' }) + } + } + return (
@@ -81,9 +113,12 @@ export function EventRail({ title, events, carouselLabel, onEventSelect }: Event ref={railRef} className={styles.carousel} data-reveal-scroller + data-native-horizontal-scroll aria-label={carouselLabel} + tabIndex={0} data-can-scroll-back={canScrollBack} data-can-scroll-forward={canScrollForward} + onKeyDown={onRailKeyDown} > {events.map((event) => ( diff --git a/src/app/components/OrgCard.module.css b/src/app/components/OrgCard.module.css index f645c6f..e21b356 100644 --- a/src/app/components/OrgCard.module.css +++ b/src/app/components/OrgCard.module.css @@ -1,10 +1,10 @@ .orgCard { display: grid; - gap: var(--space-16); + gap: clamp(10px, 1.8svh, var(--space-16)); align-content: start; width: 100%; min-height: 0; - padding: 20px; + padding: clamp(16px, 2.4svh, 20px); appearance: none; border-radius: 28px; border: 1px solid rgba(126, 225, 218, 0.28); @@ -35,38 +35,38 @@ .cardHeader { display: grid; - gap: var(--space-8); + gap: clamp(6px, 1svh, var(--space-8)); } .cardTitle { margin: 0; color: var(--c-text-light); - font: 600 24px/1.06 var(--font-display); + font: 600 clamp(20px, 3svh, 24px) / 1.06 var(--font-display); text-wrap: balance; } .cardDescription { margin: 0; color: rgba(249, 250, 250, 0.82); - font: 400 15px/1.35 var(--font-body); + font: 400 clamp(13px, 1.8svh, 15px) / 1.35 var(--font-body); } .metaList { display: grid; - gap: var(--space-8); + gap: clamp(6px, 1svh, var(--space-8)); margin: 0; } .metaRow { display: grid; - grid-template-columns: 58px minmax(0, 1fr); - gap: var(--space-16); + grid-template-columns: clamp(46px, 6svh, 58px) minmax(0, 1fr); + gap: clamp(10px, 1.6svh, var(--space-16)); align-items: start; } .metaRow dt { color: rgba(249, 250, 250, 0.54); - font: 600 12px/1.2 var(--font-body); + font: 600 clamp(11px, 1.5svh, 12px) / 1.2 var(--font-body); text-transform: uppercase; letter-spacing: 0.08em; } @@ -74,7 +74,7 @@ .metaRow dd { margin: 0; color: var(--c-text-light); - font: 500 14px/1.3 var(--font-body); + font: 500 clamp(13px, 1.7svh, 14px) / 1.3 var(--font-body); } @media (max-width: 720px) { @@ -86,3 +86,13 @@ font-size: 22px; } } + +@media (max-height: 760px) { + .orgCard { + gap: 10px; + } + + .metaRow { + grid-template-columns: 46px minmax(0, 1fr); + } +} diff --git a/src/app/components/OrgRail.module.css b/src/app/components/OrgRail.module.css index 3c2482a..f0a39d5 100644 --- a/src/app/components/OrgRail.module.css +++ b/src/app/components/OrgRail.module.css @@ -2,22 +2,22 @@ display: grid; grid-template-rows: auto auto; align-content: start; - gap: 14px; + gap: clamp(10px, 1.8svh, 14px); overflow: hidden; padding-bottom: 12px; - min-height: 390px; + min-height: clamp(280px, 48svh, 390px); } .groupHeader { display: grid; gap: 0; - padding-right: 64px; + padding-right: clamp(24px, 4vw, 64px); } .groupTitle { margin: 0; color: var(--c-text-light); - font: 600 28px/1 var(--font-display); + font: 600 clamp(24px, 3.8svh, 28px) / 1 var(--font-display); text-transform: lowercase; } @@ -26,28 +26,28 @@ grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-16); - padding-right: 64px; + padding-right: clamp(24px, 4vw, 64px); } .emptyState { display: grid; place-items: center; - min-height: 276px; - padding-right: 64px; + min-height: clamp(200px, 32svh, 276px); + padding-right: clamp(24px, 4vw, 64px); color: rgba(249, 250, 250, 0.8); - font: 500 20px/1.4 var(--font-body); + font: 500 clamp(18px, 2.8svh, 20px) / 1.4 var(--font-body); letter-spacing: 0.01em; text-align: center; } .controlButton { - width: 48px; - height: 48px; + width: clamp(42px, 6.2svh, 48px); + height: clamp(42px, 6.2svh, 48px); border: 1px solid rgba(249, 250, 250, 0.28); border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: var(--c-text-light); - font: 700 22px/1 var(--font-display); + font: 700 clamp(20px, 3svh, 22px) / 1 var(--font-display); backdrop-filter: blur(var(--glass-blur-soft)); transition: background 180ms var(--ease-premium), @@ -75,7 +75,7 @@ --right-edge-fade: 72px; display: grid; grid-auto-flow: column; - grid-auto-columns: minmax(320px, 440px); + grid-auto-columns: clamp(260px, 32vw, 440px); gap: var(--space-24); overflow-x: auto; overflow-y: visible; @@ -85,6 +85,9 @@ scroll-behavior: smooth; scrollbar-width: none; align-items: stretch; + outline: none; + overscroll-behavior-x: contain; + touch-action: pan-x pinch-zoom; mask-image: linear-gradient( 90deg, transparent 0, @@ -101,6 +104,10 @@ ); } +.carousel:focus-visible { + box-shadow: inset 0 0 0 1px rgba(249, 250, 250, 0.28); +} + .carousel[data-can-scroll-back='false'] { --left-edge-fade: 0px; } @@ -113,8 +120,11 @@ display: none; } -.carousel:global(.is-dragging) { - cursor: grabbing; +@supports (-webkit-touch-callout: none) { + .carousel { + mask-image: none; + -webkit-mask-image: none; + } } @media (max-width: 1200px) { @@ -157,3 +167,17 @@ font-size: 18px; } } + +@media (max-height: 760px) { + .groupHeader, + .railShell, + .emptyState { + padding-right: 24px; + } + + .carousel { + --left-edge-fade: 36px; + --right-edge-fade: 36px; + grid-auto-columns: clamp(240px, 30vw, 360px); + } +} diff --git a/src/app/components/OrgRail.tsx b/src/app/components/OrgRail.tsx index 1d393f2..412808b 100644 --- a/src/app/components/OrgRail.tsx +++ b/src/app/components/OrgRail.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react' +import type { KeyboardEvent } from 'react' import type { AppOrganization } from '@/app/data/organizations' +import { useSafariHorizontalRailFallback } from '@/shared/hooks/useSafariHorizontalRailFallback' import { OrgCard } from './OrgCard' import styles from './OrgRail.module.css' @@ -26,6 +28,8 @@ export function OrgRail({ const [canScrollForward, setCanScrollForward] = useState(false) const hasOrganizations = organizations.length > 0 + useSafariHorizontalRailFallback(railRef) + useEffect(() => { const rail = railRef.current if (!rail) return @@ -62,6 +66,34 @@ export function OrgRail({ }) } + const onRailKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowRight') { + event.preventDefault() + scrollRail('forward') + return + } + + if (event.key === 'ArrowLeft') { + event.preventDefault() + scrollRail('back') + return + } + + if (event.key === 'Home') { + event.preventDefault() + railRef.current?.scrollTo({ left: 0, behavior: 'smooth' }) + return + } + + if (event.key === 'End') { + const rail = railRef.current + if (!rail) return + + event.preventDefault() + rail.scrollTo({ left: rail.scrollWidth - rail.clientWidth, behavior: 'smooth' }) + } + } + return (
@@ -86,9 +118,12 @@ export function OrgRail({ ref={railRef} className={styles.carousel} data-reveal-scroller + data-native-horizontal-scroll aria-label={carouselLabel} + tabIndex={0} data-can-scroll-back={canScrollBack} data-can-scroll-forward={canScrollForward} + onKeyDown={onRailKeyDown} > {organizations.map((organization) => ( { - function TestComponent() { + function defineScrollableMetrics( + node: HTMLElement, + values: { + clientWidth: number + scrollWidth: number + initialScrollLeft?: number + }, + ) { + let scrollLeft = values.initialScrollLeft ?? 0 + + Object.defineProperty(node, 'clientWidth', { + configurable: true, + get: () => values.clientWidth, + }) + + Object.defineProperty(node, 'scrollWidth', { + configurable: true, + get: () => values.scrollWidth, + }) + + Object.defineProperty(node, 'scrollLeft', { + configurable: true, + get: () => scrollLeft, + set: (value: number) => { + scrollLeft = value + }, + }) + } + + function TestComponent({ + releaseOnEdges = false, + ignoreInteractiveElements = true, + enableDrag = true, + }: { + releaseOnEdges?: boolean + ignoreInteractiveElements?: boolean + enableDrag?: boolean + }) { const ref = useRef(null) - useHorizontalWheelScroll(ref) + useHorizontalWheelScroll(ref, { + endCutoffPx: 0, + releaseOnEdges, + ignoreInteractiveElements, + enableDrag, + }) return (
- Test +
) } @@ -19,4 +61,44 @@ describe('useHorizontalWheelScroll', () => { const div = getByTestId('test-div') expect(div).toBeInTheDocument() }) + + it('handles wheel input while there is remaining horizontal space', () => { + const { getByTestId } = render() + const div = getByTestId('test-div') + defineScrollableMetrics(div, { clientWidth: 200, scrollWidth: 500, initialScrollLeft: 120 }) + + const eventHandled = fireEvent.wheel(div, { deltaY: 60, cancelable: true }) + + expect(eventHandled).toBe(false) + expect(div.scrollLeft).toBe(186) + }) + + it('releases wheel input at the rail edge when configured', () => { + const { getByTestId } = render() + const div = getByTestId('test-div') + defineScrollableMetrics(div, { clientWidth: 200, scrollWidth: 500, initialScrollLeft: 300 }) + + const eventHandled = fireEvent.wheel(div, { deltaY: 60, cancelable: true }) + + expect(eventHandled).toBe(true) + expect(div.scrollLeft).toBe(300) + }) + + it('does not activate drag handlers when drag support is disabled', () => { + const { getByRole, getByTestId } = render( + , + ) + const div = getByTestId('test-div') + const button = getByRole('button', { name: 'Card' }) + defineScrollableMetrics(div, { clientWidth: 200, scrollWidth: 500, initialScrollLeft: 120 }) + + const addSpy = jest.spyOn(div.classList, 'add') + fireEvent.mouseDown(button, { button: 0, clientX: 100 }) + fireEvent.mouseMove(window, { clientX: 70 }) + fireEvent.mouseUp(window) + + expect(addSpy).not.toHaveBeenCalled() + expect(div.scrollLeft).toBe(120) + addSpy.mockRestore() + }) }) diff --git a/src/shared/hooks/useHorizontalWheelScroll.ts b/src/shared/hooks/useHorizontalWheelScroll.ts index 59b18c0..ee5a30b 100644 --- a/src/shared/hooks/useHorizontalWheelScroll.ts +++ b/src/shared/hooks/useHorizontalWheelScroll.ts @@ -4,6 +4,9 @@ import type { RefObject } from 'react' type HorizontalWheelOptions = { speed?: number endCutoffPx?: number + releaseOnEdges?: boolean + ignoreInteractiveElements?: boolean + enableDrag?: boolean } /** @@ -13,17 +16,76 @@ type HorizontalWheelOptions = { * @param options - Configuration options for the scrolling behavior. * @param options.speed - The multiplier for scroll speed when using the mouse wheel (default: 1.1). * @param options.endCutoffPx - The number of pixels from the end of the scroll width to treat as the maximum scroll threshold (default: 180). + * @param options.releaseOnEdges - Allows parent scrollers to receive wheel input once this scroller reaches an edge (default: false). + * @param options.ignoreInteractiveElements - Prevents drag-to-scroll from starting on controls like buttons or inputs (default: true). + * @param options.enableDrag - Enables mouse drag-to-scroll interactions for the target scroller (default: true). */ export function useHorizontalWheelScroll( scrollerRef: RefObject, options: HorizontalWheelOptions = {}, ): void { - const { speed = 1.1, endCutoffPx = 180 } = options + const { + speed = 1.1, + endCutoffPx = 180, + releaseOnEdges = false, + ignoreInteractiveElements = true, + enableDrag = true, + } = options useEffect(() => { const scroller = scrollerRef.current if (!scroller) return + const getNestedHorizontalScroller = (event: WheelEvent) => { + const eventPath = typeof event.composedPath === 'function' ? event.composedPath() : [] + + for (const node of eventPath) { + if (!(node instanceof HTMLElement)) { + continue + } + + if (node === scroller) { + break + } + + if (node.hasAttribute('data-native-horizontal-scroll')) { + return node + } + } + + const target = event.target as HTMLElement | null + const nestedScroller = target?.closest( + '[data-native-horizontal-scroll]', + ) as HTMLElement | null + if (!nestedScroller || nestedScroller === scroller) { + return null + } + + return nestedScroller + } + + const canNestedScrollerConsume = (event: WheelEvent, intent: number) => { + const nestedScroller = getNestedHorizontalScroller(event) + if (!nestedScroller) { + return false + } + + const maxScrollLeft = Math.max(0, nestedScroller.scrollWidth - nestedScroller.clientWidth) + if (maxScrollLeft <= 0) { + return false + } + + if (intent < 0) { + return nestedScroller.scrollLeft > 0 + } + + if (intent > 0) { + return nestedScroller.scrollLeft < maxScrollLeft + } + + return false + } + const getMaxScrollLeft = () => Math.max(0, scroller.scrollWidth - scroller.clientWidth - endCutoffPx) @@ -45,9 +107,21 @@ export function useHorizontalWheelScroll( const intent = Math.abs(event.deltaY) > Math.abs(event.deltaX) ? event.deltaY : event.deltaX if (intent === 0) return + if (canNestedScrollerConsume(event, intent)) { + return + } + + const maxScrollLeft = getMaxScrollLeft() + const isAtStart = scroller.scrollLeft <= 0 + const isAtEnd = scroller.scrollLeft >= maxScrollLeft + + if (releaseOnEdges && ((intent < 0 && isAtStart) || (intent > 0 && isAtEnd))) { + return + } + event.preventDefault() + event.stopPropagation() const next = scroller.scrollLeft + intent * speed - const maxScrollLeft = getMaxScrollLeft() scroller.scrollLeft = Math.min(maxScrollLeft, Math.max(0, next)) } @@ -66,7 +140,9 @@ export function useHorizontalWheelScroll( if (event.button !== 0) return const target = event.target as HTMLElement | null - if (target?.closest('button, input, textarea, select, label')) return + if (ignoreInteractiveElements && target?.closest('button, input, textarea, select, label')) { + return + } isMouseDragging = true hasActivatedDrag = false @@ -125,20 +201,27 @@ export function useHorizontalWheelScroll( scroller.addEventListener('wheel', onWheel, { passive: false }) scroller.addEventListener('scroll', onScroll, { passive: true }) - scroller.addEventListener('mousedown', onMouseDown) - scroller.addEventListener('dragstart', onNativeDragStart) - scroller.addEventListener('click', onClickCapture, true) - window.addEventListener('mousemove', onMouseMove, { passive: false }) - window.addEventListener('mouseup', endMouseDrag) + if (enableDrag) { + scroller.addEventListener('mousedown', onMouseDown) + scroller.addEventListener('dragstart', onNativeDragStart) + scroller.addEventListener('click', onClickCapture, true) + window.addEventListener('mousemove', onMouseMove, { passive: false }) + window.addEventListener('mouseup', endMouseDrag) + } + return () => { scroller.removeEventListener('wheel', onWheel) scroller.removeEventListener('scroll', onScroll) - scroller.removeEventListener('mousedown', onMouseDown) - scroller.removeEventListener('dragstart', onNativeDragStart) - scroller.removeEventListener('click', onClickCapture, true) - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', endMouseDrag) + + if (enableDrag) { + scroller.removeEventListener('mousedown', onMouseDown) + scroller.removeEventListener('dragstart', onNativeDragStart) + scroller.removeEventListener('click', onClickCapture, true) + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', endMouseDrag) + } + scroller.classList.remove('is-dragging') } - }, [scrollerRef, speed, endCutoffPx]) + }, [scrollerRef, speed, endCutoffPx, releaseOnEdges, ignoreInteractiveElements, enableDrag]) } diff --git a/src/shared/hooks/useSafariHorizontalRailFallback.ts b/src/shared/hooks/useSafariHorizontalRailFallback.ts new file mode 100644 index 0000000..d61fa9b --- /dev/null +++ b/src/shared/hooks/useSafariHorizontalRailFallback.ts @@ -0,0 +1,58 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' + +function isSafariBrowser() { + if (typeof navigator === 'undefined') { + return false + } + + const userAgent = navigator.userAgent + return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|EdgiOS|FxiOS/i.test(userAgent) +} + +/** + * Adds a Safari-only wheel fallback for nested horizontal rails. + * + * Safari can fail to hand trackpad and wheel gestures to nested horizontal + * overflow containers when a parent also participates in scroll handling. + */ +export function useSafariHorizontalRailFallback( + railRef: RefObject, + speed = 1, +): void { + useEffect(() => { + const rail = railRef.current + if (!rail || !isSafariBrowser()) return + + const onWheel = (event: WheelEvent) => { + const maxScrollLeft = Math.max(0, rail.scrollWidth - rail.clientWidth) + if (maxScrollLeft <= 0) { + return + } + + const intent = Math.abs(event.deltaX) > 0 ? event.deltaX : event.deltaY + if (intent === 0) { + return + } + + const isAtStart = rail.scrollLeft <= 0 + const isAtEnd = rail.scrollLeft >= maxScrollLeft + + if ((intent < 0 && isAtStart) || (intent > 0 && isAtEnd)) { + return + } + + event.preventDefault() + event.stopPropagation() + + const next = rail.scrollLeft + intent * speed + rail.scrollLeft = Math.min(maxScrollLeft, Math.max(0, next)) + } + + rail.addEventListener('wheel', onWheel, { passive: false }) + + return () => { + rail.removeEventListener('wheel', onWheel) + } + }, [railRef, speed]) +}