tcmd: Accordion and popover md elements

This commit is contained in:
2024-02-28 21:41:12 -07:00
parent 2e9bfa1557
commit ff0a4280e2
22 changed files with 729 additions and 68 deletions

80
lib/accordion/index.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { FC, PropsWithChildren, ReactNode, useCallback, useState } from "react";
interface IProps {
expandOnHover?: boolean;
expanded?: boolean;
title?: ReactNode;
}
export const Accordion: FC<PropsWithChildren<IProps>> = (
{ children, expandOnHover, expanded, title },
) => {
const [open, setOpen] = useState(false);
return (
<div
data-expanded={open || expanded}
data-expandonhover={expandOnHover}
className={(expandOnHover ? "group/hover" : "group/controlled") +
" group"}
onClick={() => !title && !expandOnHover && setOpen(!open)}
>
{!!title && (
<div
className="flex justify-between cursor-pointer"
onClick={() => !expandOnHover && setOpen(!open)}
>
{title}
<div
className={`
group-hover/hover:-rotate-180
group-data-[expanded]:-rotate-180
transition-transform
duration-500
grid
rounded-full
h-min
mr-2
mt-1
scale-y-50
`}
>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center">
</span>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center">
</span>
</div>
</div>
)}
{children}
</div>
);
};
export const AccordionContent: FC<PropsWithChildren> = ({ children }) => {
const [height, setHeight] = useState(0);
const updateRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
setHeight(node.clientHeight);
} else {
setHeight(0);
}
}, []);
const Child = () => (
<div className="absolute bottom-0 w-full" ref={updateRef}>
{children}
</div>
);
return (
<div className="relative overflow-hidden">
{<Child />}
<span
style={{ ["--v-height" as never]: height + "px" }}
className="w-0 block h-0 group-hover/hover:h-variable group-data-[expanded]/controlled:h-variable transition-all duration-700"
/>
</div>
);
};

View File

@@ -0,0 +1,170 @@
import {
Dispatch,
FC,
PropsWithChildren,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { bulkRound } from "../../utils/bulkRound";
import { clamp } from "../../utils/clamp";
import { Portal } from "../../portal/components";
type edge = "top" | "bottom" | "left" | "right";
type alignment = edge | "centered";
interface IProps {
preferredEdge: edge;
preferredAlign: alignment;
relativeElement: HTMLElement;
spacing?: number;
isClosing: boolean;
isClosed: boolean;
setHover: Dispatch<SetStateAction<boolean>>;
}
type position = { top: number; left: number; width?: number };
export const PoppableContent: FC<PropsWithChildren<IProps>> = (
{
preferredAlign,
preferredEdge,
children,
relativeElement,
spacing = 10,
setHover,
isClosing,
isClosed,
},
) => {
const [popRef, setPopRef] = useState<HTMLDivElement>();
const updateRef = useCallback((node: HTMLDivElement) => {
if (!node) return;
setPopRef(node);
}, []);
const getAlignment = useCallback(
(
relX: number,
relY: number,
relWidth: number,
relHeight: number,
popWidth: number,
popHeight: number,
edge: edge,
align: alignment,
): position => {
const pos = {
top: relY,
left: relX,
};
switch (align) {
case "centered":
pos.top = relY + (relHeight / 2) - (popHeight / 2);
pos.left = relX + (relWidth / 2) - (popWidth / 2);
break;
case "top":
pos.top = relY;
break;
case "bottom":
pos.top = relY + relHeight - popHeight;
break;
case "left":
pos.left = relX;
break;
case "right":
pos.left = relX + relWidth - popWidth;
break;
}
return pos;
},
[],
);
const getPosition = useCallback(
(popWidth: number, popHeight: number, edge: edge, align: alignment) => {
const rel = relativeElement.getBoundingClientRect();
const [relX, relY, relWidth, relHeight] = bulkRound(
rel.x,
rel.y,
rel.width,
rel.height,
);
const pos: position = { top: 100, left: 100 };
const alignment = getAlignment(
relX,
relY,
relWidth,
relHeight,
popWidth,
popHeight,
edge,
align,
);
switch (edge) {
case "top":
pos.top = relY - popHeight - spacing +
document.documentElement.scrollTop;
pos.left = alignment.left;
break;
case "bottom":
pos.top = relY + relHeight + spacing +
document.documentElement.scrollTop;
pos.left = alignment.left;
break;
case "left":
pos.left = relX - popWidth - spacing;
pos.top = alignment.top + document.documentElement.scrollTop;
break;
case "right":
pos.left = relX + relWidth + spacing;
pos.top = alignment.top + document.documentElement.scrollTop;
break;
}
return pos;
},
[getAlignment, relativeElement, spacing],
);
const getClampedPosition = useCallback(() => {
if (!popRef) return { opacity: 0 };
const pop = popRef.getBoundingClientRect();
const [popWidth, popHeight] = bulkRound(pop.width, pop.height);
const pos = getPosition(popWidth, popHeight, preferredEdge, preferredAlign);
const { innerHeight, innerWidth } = window;
pos.top = ["left", "right"].includes(preferredEdge)
? clamp(pos.top, spacing, innerHeight - popHeight - spacing)
: pos.top;
pos.left = ["top", "bottom"].includes(preferredEdge)
? clamp(pos.left, spacing, innerWidth - popWidth - spacing)
: pos.left;
return pos;
}, [popRef, getPosition, preferredEdge, preferredAlign, spacing]);
return (
<Portal>
<div
ref={updateRef}
style={getClampedPosition()}
data-fading={isClosing}
data-visible={!isClosing}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
// className="absolute w-[400px] border"
className="poppable"
>
{children}
</div>
</Portal>
);
};

View File

@@ -0,0 +1,59 @@
import {
FC,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { PoppableContent } from "./poppable-content";
import { useDebounce } from "../../../hooks/useDebounce";
interface IProps {
content: ReactNode;
className?: string;
preferredEdge: "top" | "bottom" | "left" | "right";
preferredAlign: "centered" | "top" | "bottom" | "left" | "right";
spacing?: number;
}
export const Poppable: FC<PropsWithChildren<IProps>> = (
{ className, content, children, preferredEdge, preferredAlign, spacing },
) => {
const [isHovered, setIsHovered] = useState(false);
const closing = useDebounce(!isHovered, 1000);
const closed = useDebounce(closing, 300);
const [ref, setRef] = useState<HTMLElement>();
const updateRef = useCallback((node: HTMLElement) => {
if (!node) return;
setRef(node);
}, []);
return (
<>
<span
ref={updateRef}
className={className}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
</span>
{!!ref && (
<PoppableContent
preferredAlign={preferredAlign}
preferredEdge={preferredEdge}
spacing={spacing}
isClosing={closing}
isClosed={closed}
relativeElement={ref}
setHover={setIsHovered}
>
{content}
</PoppableContent>
)}
</>
);
};

View File

@@ -0,0 +1,27 @@
import { FC, PropsWithChildren, useEffect, useState } from "react";
import { createPortal } from "react-dom";
interface IProps {
className?: string;
el?: string;
}
export const Portal: FC<PropsWithChildren<IProps>> = (
{ children, className = "root-portal", el = "div" },
) => {
const [container] = useState(() => {
// This will be executed only on the initial render
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
return document.getElementById("root-portal")!;
});
useEffect(() => {
container.classList.add(className);
document.body.appendChild(container);
return () => {
document.body.removeChild(container);
};
}, [className, container]);
return createPortal(children, container);
};

View File

@@ -21,7 +21,6 @@ const tokenize = (body: string) => {
let openBT = blockTokens.findLast((bt) => !bt.closed);
if (block) {
if (typeof block === "string") {
console.log(block);
if (openBT) {
openBT.closed = true;
}

View File

@@ -24,7 +24,7 @@ export const inlineTokens: {
},
},
{
rx: /(?<!\!)\[(.*?)\]\((.*?)\)/g,
rx: /(?<![\!\?|^])\[(.*?)\]\((.*?)\)/g,
create(content, start, end, tokens) {
const [_, label, href] = content;
tokens.push({
@@ -60,4 +60,22 @@ export const inlineTokens: {
return l;
},
},
{
rx: /\^\[(.*?)\]\((.*?)\)/g,
create(content, start, end, tokens) {
const [_, text, popover] = content;
tokens.push({
content: text,
end,
start,
type: "popover",
data: {
popover,
},
});
},
replace(l) {
return l;
},
},
];

View File

@@ -41,4 +41,17 @@ const blockTokens: {
};
},
},
{
rx: /\[accordion\s?([a-z\s]*)\]/,
closeRx: /\[\/accordion\]/,
create(line) {
const title = line.match(this.rx)?.at(1);
return {
type: "accordion",
metadata: { title },
children: [],
closed: false,
};
},
},
];

1
lib/utils/bulkRound.ts Normal file
View File

@@ -0,0 +1 @@
export const bulkRound = (...args: number[]): number[] => args.map(n => Math.round(n));

2
lib/utils/clamp.ts Normal file
View File

@@ -0,0 +1,2 @@
export const clamp = (value: number, min: number, max: number) =>
Math.max(Math.min(value, max), min)