166 lines
4.1 KiB
TypeScript
166 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;
|