tcmd: Accordion and popover md elements
This commit is contained in:
80
lib/accordion/index.tsx
Normal file
80
lib/accordion/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
170
lib/poppables/components/poppable-content.tsx
Normal file
170
lib/poppables/components/poppable-content.tsx
Normal 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>
|
||||
);
|
||||
};
|
59
lib/poppables/components/poppable.tsx
Normal file
59
lib/poppables/components/poppable.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
27
lib/portal/components/index.ts
Normal file
27
lib/portal/components/index.ts
Normal 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);
|
||||
};
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@@ -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
1
lib/utils/bulkRound.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const bulkRound = (...args: number[]): number[] => args.map(n => Math.round(n));
|
2
lib/utils/clamp.ts
Normal file
2
lib/utils/clamp.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const clamp = (value: number, min: number, max: number) =>
|
||||
Math.max(Math.min(value, max), min)
|
Reference in New Issue
Block a user