Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

table poc #121

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 62 additions & 23 deletions components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = {
type TableProps<T extends { readonly id: string } = { readonly id: string }> = {
readonly data: readonly T[];
readonly columns: ReadonlyArray<readonly [key: keyof T, label: string]>;
readonly columns: ReadonlyArray<readonly [key: keyof T & string, label: string]>;
};

export const Table = <T extends { readonly id: string }>({ data, columns }: TableProps<T>) => (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
{columns.map(([key, label]) => (
<th key={key as string}>{label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{columns.map(([key]) => (
<td key={key as string}>{row[key]}</td>
))}
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) => (
<As style={styles?.[index]} key={key}>
{label}
</As>
))}
</>
);
};

const intersectionObserverOptions = {};

export const Table = <T extends { readonly id: string }>({ data, columns }: TableProps<T>) => {
const { tableRef, floatingHeaderRef } = useFloatingTableHeader<HTMLDivElement>();

const { setRef, entry } = useIntersectionObserver<HTMLTableSectionElement>(
intersectionObserverOptions,
);

console.log({ entry });

return (
<div className={styles.tableWrapper}>
<div ref={floatingHeaderRef} aria-hidden={true}>
<TableHeaderRow columns={columns} as="span" />
</div>
<table ref={tableRef} className={styles.table}>
<thead ref={setRef}>
<tr>
<TableHeaderRow columns={columns} as="th" />
</tr>
))}
</tbody>
</table>
</div>
);
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{columns.map(([key]) => (
<td key={key}>{row[key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
13 changes: 7 additions & 6 deletions components/Table/table.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
79 changes: 79 additions & 0 deletions components/Table/useFloatingTableHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef } from 'react';

const rafThrottle = <T extends readonly unknown[]>(
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 = <T extends HTMLElement>() => {
const tableRef = useRef<HTMLTableElement>(null);
const floatingHeaderRef = useRef<T>(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 };
};
100 changes: 100 additions & 0 deletions hooks/useIntersectionObserver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react';

import { useWillUnmount } from './useWillUnmount';

type UseIntersectionObserverArgs = Pick<IntersectionObserverInit, 'rootMargin' | 'threshold'>;
type ObserverCallback = (entry: IntersectionObserverEntry) => void;
type Observer = {
readonly key: string;
readonly intersectionObserver: IntersectionObserver;
// eslint-disable-next-line
readonly elementToCallback: Map<Element, ObserverCallback>;
};

export const useIntersectionObserver = <T extends Element = HTMLElement>(
options: UseIntersectionObserverArgs,
) => {
const unobserve = useRef<() => void>();

const [entry, setEntry] = useState<IntersectionObserverEntry | null>(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<string, Observer>();

const createObserver = (options: UseIntersectionObserverArgs) => {
const key = JSON.stringify(options);
if (observers.has(key)) {
return observers.get(key)!;
}

const elementToCallback = new Map<Element, ObserverCallback>();
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 <T extends Element>(
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;
};
})();
4 changes: 1 addition & 3 deletions hooks/useSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { InferType, ObjectSchema } from 'yup';

const replacementsPattern = /\[([^[\]\s]+)\]/gi;

export const useSearch = <S extends ObjectSchema<any>>(schema: S) => {
export const useSmartQuery = <S extends ObjectSchema<any>>(schema: S) => {
const { pathname, query: routerQuery, push } = useRouter();

/**
Expand All @@ -30,8 +30,6 @@ export const useSearch = <S extends ObjectSchema<any>>(schema: S) => {
};
}, [pathname, routerQuery, schema]);

console.log({ params, query });

const changeQuery = useCallback(
(query: InferType<S>) => {
const filteredQuery = Object.fromEntries(
Expand Down
7 changes: 7 additions & 0 deletions hooks/useWillUnmount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { EffectCallback } from 'react';
import { useEffect } from 'react';

/* eslint-disable react-hooks/exhaustive-deps */
export const useWillUnmount = (cb: ReturnType<EffectCallback>) => {
useEffect(() => cb, []);
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1970,6 +1970,11 @@
"@types/prop-types" "*"
csstype "^3.0.2"

"@types/[email protected]":
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"
Expand Down