114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
/**
|
|
* Renders the row of tabs of the index, profile and search pages.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
|
|
|
/** A tab. */
|
|
export interface Tab<T extends string> {
|
|
/** What {@link TabBar} reports when it is chosen. */
|
|
key: T;
|
|
/** What it shows. */
|
|
label: ReactNode;
|
|
}
|
|
|
|
/** The props of {@link TabBar}. */
|
|
export interface TabBarProps<T extends string> {
|
|
/** The tabs, in order. */
|
|
tabs: Tab<T>[];
|
|
/** The active tab's key. */
|
|
activeTab: T;
|
|
/** Called with the key of the tab chosen. */
|
|
onChange: (key: T) => void;
|
|
/** An extra class for the scroller. */
|
|
className?: string;
|
|
/** An extra class for the row. */
|
|
innerClassName?: string;
|
|
/**
|
|
* Optional content rendered before the tabs (e.g. a scope selector). Sits
|
|
* in the same line, aligned with them, and stays where it is while they
|
|
* scroll: it is outside the row that scrolls, which clips them at its edge.
|
|
*/
|
|
leading?: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Renders a row of tabs that scrolls when they don't all fit, keeping the
|
|
* active one in view.
|
|
*/
|
|
export function TabBar<T extends string>(
|
|
{ tabs, activeTab, onChange, className, innerClassName, leading }:
|
|
TabBarProps<T>,
|
|
): React.JSX.Element {
|
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
const [overflowing, setOverflowing] = useState(false);
|
|
// Tabs hidden past either edge, which the stylesheet fades only then, so a
|
|
// fade never dims a tab that is fully in view.
|
|
const [moreLeft, setMoreLeft] = useState(false);
|
|
const [moreRight, setMoreRight] = useState(false);
|
|
|
|
useLayoutEffect(() => {
|
|
const row = rowRef.current;
|
|
if (!row) return;
|
|
const measure = () => {
|
|
setOverflowing(row.scrollWidth > row.clientWidth);
|
|
setMoreLeft(row.scrollLeft > 1);
|
|
setMoreRight(row.scrollLeft + row.clientWidth < row.scrollWidth - 1);
|
|
};
|
|
const resize = new ResizeObserver(measure);
|
|
resize.observe(row);
|
|
for (const child of row.children) resize.observe(child);
|
|
row.addEventListener("scroll", measure, { passive: true });
|
|
measure();
|
|
return () => {
|
|
resize.disconnect();
|
|
row.removeEventListener("scroll", measure);
|
|
};
|
|
}, [tabs.length]);
|
|
|
|
// When they don't all fit, keep the active tab in view, clear of the fade
|
|
// over the row's right edge, scrolling the row alone, never the page.
|
|
useLayoutEffect(() => {
|
|
const row = rowRef.current;
|
|
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
|
|
if (!row || !active || !overflowing) return;
|
|
// The fade is 2rem wide (--tab-fade-end on .feed-sort).
|
|
const fade =
|
|
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
|
|
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
|
|
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
|
|
else if (b.right > r.right - fade) {
|
|
row.scrollLeft += b.right - r.right + fade;
|
|
}
|
|
}, [activeTab, overflowing]);
|
|
|
|
return (
|
|
<div
|
|
className={`feed-sort-scroller${
|
|
moreLeft ? " feed-sort-scroller--more-left" : ""
|
|
}${moreRight ? " feed-sort-scroller--more" : ""}${
|
|
className ? ` ${className}` : ""
|
|
}`}
|
|
>
|
|
{leading && <div className="feed-sort-leading">{leading}</div>}
|
|
<div
|
|
ref={rowRef}
|
|
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
|
|
>
|
|
{tabs.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className={`feed-sort-btn${activeTab === key ? " active" : ""}`}
|
|
onClick={() => onChange(key)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|