diff --git a/components/Table/Table.tsx b/components/Table/Table.tsx index 0323a67f..0f425bb1 100644 --- a/components/Table/Table.tsx +++ b/components/Table/Table.tsx @@ -1,29 +1,68 @@ +import type { CSSProperties } from 'react'; +import { useEffect } from 'react'; + +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; + import styles from './table.module.scss'; +import { useFloatingTableHeader } from './useFloatingTableHeader'; -type TableProps = { +type TableProps = { readonly data: readonly T[]; - readonly columns: ReadonlyArray; + readonly columns: ReadonlyArray; }; -export const Table = ({ data, columns }: TableProps) => ( -
- - - - {columns.map(([key, label]) => ( - - ))} - - - - {data.map((row) => ( - - {columns.map(([key]) => ( - - ))} +const TableHeaderRow = ({ + as: As, + columns, + styles, +}: { + readonly as: 'span' | 'th'; + readonly styles?: readonly CSSProperties[]; + readonly columns: readonly (readonly [string, string])[]; +}) => { + return ( + <> + {columns.map(([key, label], index) => ( + + {label} + + ))} + + ); +}; + +const intersectionObserverOptions = {}; + +export const Table = ({ data, columns }: TableProps) => { + const { tableRef, floatingHeaderRef } = useFloatingTableHeader(); + + const { setRef, entry } = useIntersectionObserver( + intersectionObserverOptions, + ); + + console.log({ entry }); + + return ( +
+
+ +
+
{label}
{row[key]}
+ + + - ))} - -
-
-); + + + {data.map((row) => ( + + {columns.map(([key]) => ( + {row[key]} + ))} + + ))} + + + + ); +}; diff --git a/components/Table/table.module.scss b/components/Table/table.module.scss index 410b0871..aa9feabe 100644 --- a/components/Table/table.module.scss +++ b/components/Table/table.module.scss @@ -8,6 +8,10 @@ padding: 0.5rem 1rem; border: 1px solid var(--gray-dark-border); + &:nth-child(2) { + width: 27vw; + } + & + th, & + td { border-left: none; @@ -34,10 +38,7 @@ .tableWrapper { width: 100%; - overflow: auto; - /* - In order to be able to make elements inside "sticky", the container has to have a fixed height - https://uxdesign.cc/position-stuck-96c9f55d9526 - */ - height: 80vh; + overflow-x: auto; + position: relative; + -webkit-overflow-scrolling: touch; } diff --git a/components/Table/useFloatingTableHeader.ts b/components/Table/useFloatingTableHeader.ts new file mode 100644 index 00000000..ef6201f1 --- /dev/null +++ b/components/Table/useFloatingTableHeader.ts @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useRef } from 'react'; + +const rafThrottle = ( + fn: (...args: T) => any, +): ((...args: T) => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + const fnToThrottle = window.requestIdleCallback || window.requestAnimationFrame; + + let isPainting = false; + return (...args) => { + if (isPainting) { + return; + } + isPainting = true; + fnToThrottle( + () => { + fn(...args); + setTimeout(() => (isPainting = false)); + }, + { timeout: 100 }, + ); + }; +}; + +const cssPropsToCopy = [ + 'display', + 'verticalAlign', + 'textAlign', + 'font', + 'background', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', + 'padding', +] as const; + +export const useFloatingTableHeader = () => { + const tableRef = useRef(null); + const floatingHeaderRef = useRef(null); + + const update = useMemo( + () => + rafThrottle(() => { + if (!tableRef.current || !floatingHeaderRef.current) { + return; + } + + const headers = tableRef.current.querySelectorAll('thead th'); + const spans = floatingHeaderRef.current.querySelectorAll('span'); + const tableRect = tableRef.current.getBoundingClientRect(); + + Array.from(headers) + .map((th) => ({ thRect: th.getBoundingClientRect(), thStyle: getComputedStyle(th) })) + .forEach(({ thRect, thStyle }, index) => { + spans[index].style.height = `${thRect.height}px`; + spans[index].style.width = `${thRect.width}px`; + cssPropsToCopy.forEach((prop) => (spans[index].style[prop] = thStyle[prop])); + }); + + floatingHeaderRef.current.style.zIndex = '1'; + floatingHeaderRef.current.style.position = 'absolute'; + floatingHeaderRef.current.style.top = '0'; + floatingHeaderRef.current.style.width = `${tableRect.width}px`; + }), + [], + ); + + useEffect(() => { + update(); + + window.addEventListener('resize', update, { passive: true }); + return () => window.removeEventListener('resize', update); + }, [update]); + + return { tableRef, floatingHeaderRef }; +}; diff --git a/hooks/useIntersectionObserver.tsx b/hooks/useIntersectionObserver.tsx new file mode 100644 index 00000000..628d69e6 --- /dev/null +++ b/hooks/useIntersectionObserver.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useWillUnmount } from './useWillUnmount'; + +type UseIntersectionObserverArgs = Pick; +type ObserverCallback = (entry: IntersectionObserverEntry) => void; +type Observer = { + readonly key: string; + readonly intersectionObserver: IntersectionObserver; + // eslint-disable-next-line + readonly elementToCallback: Map; +}; + +export const useIntersectionObserver = ( + options: UseIntersectionObserverArgs, +) => { + const unobserve = useRef<() => void>(); + + const [entry, setEntry] = useState(null); + + const setRef = useCallback( + (el: T) => { + console.log({ el }); + + if (unobserve.current) { + unobserve.current(); + unobserve.current = undefined; + } + + if (el && el.tagName) { + unobserve.current = observe(el, setEntry, options); + } + }, + [options], + ); + + useWillUnmount(() => { + if (unobserve.current) { + unobserve.current(); + unobserve.current = undefined; + } + }); + + if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') { + return { setRef: () => {}, entry: null } as const; + } + + return { setRef, entry } as const; +}; + +const observe = (() => { + const observers = new Map(); + + const createObserver = (options: UseIntersectionObserverArgs) => { + const key = JSON.stringify(options); + if (observers.has(key)) { + return observers.get(key)!; + } + + const elementToCallback = new Map(); + const intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const callback = elementToCallback.get(entry.target); + if (callback) { + callback(entry); + } + }); + }, options); + + const observer: Observer = { + key, + elementToCallback, + intersectionObserver, + }; + observers.set(key, observer); + + return observer; + }; + + return ( + el: T, + callback: ObserverCallback, + options: UseIntersectionObserverArgs, + ) => { + const { key, elementToCallback, intersectionObserver } = createObserver(options); + elementToCallback.set(el, callback); + intersectionObserver.observe(el); + + const unobserve = () => { + intersectionObserver.unobserve(el); + elementToCallback.delete(el); + + if (elementToCallback.size === 0) { + intersectionObserver.disconnect(); + observers.delete(key); + } + }; + return unobserve; + }; +})(); diff --git a/hooks/useSearch.tsx b/hooks/useSearch.tsx index f23f0f37..471edf39 100644 --- a/hooks/useSearch.tsx +++ b/hooks/useSearch.tsx @@ -4,7 +4,7 @@ import type { InferType, ObjectSchema } from 'yup'; const replacementsPattern = /\[([^[\]\s]+)\]/gi; -export const useSearch = >(schema: S) => { +export const useSmartQuery = >(schema: S) => { const { pathname, query: routerQuery, push } = useRouter(); /** @@ -30,8 +30,6 @@ export const useSearch = >(schema: S) => { }; }, [pathname, routerQuery, schema]); - console.log({ params, query }); - const changeQuery = useCallback( (query: InferType) => { const filteredQuery = Object.fromEntries( diff --git a/hooks/useWillUnmount.tsx b/hooks/useWillUnmount.tsx new file mode 100644 index 00000000..a3161a2a --- /dev/null +++ b/hooks/useWillUnmount.tsx @@ -0,0 +1,7 @@ +import type { EffectCallback } from 'react'; +import { useEffect } from 'react'; + +/* eslint-disable react-hooks/exhaustive-deps */ +export const useWillUnmount = (cb: ReturnType) => { + useEffect(() => cb, []); +}; diff --git a/package.json b/package.json index 14d2d2f1..eb35cd4f 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@types/pino": "6.3.4", "@types/react": "17.0.0", "@types/react-dom": "17.0.0", + "@types/requestidlecallback": "0.3.1", "@types/yup": "0.29.11", "@types/zeit__next-source-maps": "0.0.2", "@typescript-eslint/eslint-plugin": "4.12.0", diff --git a/yarn.lock b/yarn.lock index 13161f1b..747522b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1970,6 +1970,11 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/requestidlecallback@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@types/requestidlecallback/-/requestidlecallback-0.3.1.tgz#34bb89753b1cdc72d0547522527b1cb0f02b5ec4" + integrity sha512-BnnRkgWYijCIndUn+LgoqKHX/hNpJC5G03B9y7mZya/C2gUQTSn75fEj3ZP1/Rl2E6EYeXh2/7/8UNEZ4X7HuQ== + "@types/sax@*": version "1.2.1" resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.1.tgz#e0248be936ece791a82db1a57f3fb5f7c87e8172"