tutu/src/material/Tabs.tsx
2024-07-14 20:28:44 +08:00

165 lines
4.1 KiB
TypeScript

import {
ParentComponent,
createContext,
createEffect,
createMemo,
createRenderEffect,
createSignal,
useContext,
type Signal,
} from "solid-js";
import { css } from "solid-styled";
const TabListContext = /* @__PURE__ */ createContext<{
focusOn: Signal<HTMLElement[]>;
}>();
export function useTabListContext() {
const result = useContext(TabListContext);
if (!result) {
throw new TypeError("tab list context is not found");
}
return result;
}
const ANIM_SPEED = 160 / 110; // 160px/110ms
const TABLIST_FOCUS_CLASS = "tablist-focus";
const Tabs: ParentComponent<{
offset?: number;
onFocusChanged?: (element: HTMLElement[]) => void;
}> = (props) => {
let self: HTMLDivElement;
const [focusOn, setFocusOn] = createSignal<HTMLElement[]>([]);
createRenderEffect<HTMLElement[] | undefined>((lastFocusElement) => {
const current = focusOn();
if (lastFocusElement) {
for (const e of lastFocusElement) {
e.classList.remove(TABLIST_FOCUS_CLASS);
}
}
for (const e of current) {
e.classList.add("tablist-focus");
}
return current;
});
createRenderEffect(() => {
const callback = props.onFocusChanged;
if (!callback) return;
callback(focusOn());
});
let lastLeft = 0;
let lastWidth = 0;
const getNearestDistance = (
srcRect: { x: number; width: number },
prevEl: Element | null,
nextEl: Element | null,
offset?: number,
) => {
if (!offset || offset === 0) return [0, 0] as const;
if (offset > 0) {
if (!nextEl) return [0, 0] as const;
const rect = nextEl.getBoundingClientRect();
return [
(rect.x - srcRect.x) * offset,
(rect.width - srcRect.width) * offset,
] as const;
} else {
if (!prevEl) return [0, 0] as const;
const rect = prevEl.getBoundingClientRect();
return [
(rect.x - srcRect.x) * offset,
(srcRect.width - rect.width) * offset,
] as const;
}
};
const focusBoundingClientRect = () => {
return focusOn()
.map((x) => x.getBoundingClientRect())
.reduce(
(p, c) => {
return {
x: Math.min(p.x, c.x),
width: p.width + c.width,
};
},
{ x: +Infinity, width: 0 },
);
};
const focusSiblings = () => {
const rects = focusOn().map((x) => [x, x.getBoundingClientRect()] as const);
if (rects.length === 0) return [null, null] as const;
rects.sort(([, rect1], [, rect2]) => rect1.x - rect2.x);
return [
rects[0][0].previousElementSibling,
rects[rects.length - 1][0].nextElementSibling,
] as const;
};
const indicator = () => {
const el = focusOn();
if (!el) {
return ["0px", "0px", "110ms", "110ms"] as const;
}
const rect = focusBoundingClientRect();
const rootRect = self.getBoundingClientRect();
const left = rect.x - rootRect.x;
const width = rect.width;
const [prevEl, nextEl] = focusSiblings();
const [offset, widthChange] = getNearestDistance(
rect,
prevEl,
nextEl,
props.offset,
);
const result = [
`${left + offset}px`,
`${width + widthChange}px`,
`${Math.max(Math.floor(Math.abs(left + offset - lastLeft)), 160) * ANIM_SPEED}ms`,
`${Math.max(Math.floor(Math.abs(width - lastWidth)), 160) * ANIM_SPEED}ms`,
] as const;
lastLeft = left;
lastWidth = width;
return result;
};
css`
.tablist {
width: 100%;
position: relative;
white-space: nowrap;
overflow-x: auto;
&::after {
transition:
left ${indicator()[2]} var(--tutu-anim-curve-std),
width ${indicator()[3]} var(--tutu-anim-curve-std);
position: absolute;
content: "";
display: block;
background-color: white;
height: 2px;
width: ${indicator()[1]};
left: ${indicator()[0]};
bottom: 0;
}
}
`;
return (
<TabListContext.Provider value={{ focusOn: [focusOn, setFocusOn] }}>
<div ref={self!} class="tablist" role="tablist">
{props.children}
</div>
</TabListContext.Provider>
);
};
export default Tabs;