tcmd: Accordion and popover md elements
This commit is contained in:
parent
2e9bfa1557
commit
ff0a4280e2
5
actions/temp.ts
Normal file
5
actions/temp.ts
Normal file
@ -0,0 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
export const readMD = async () => await readFile("./test.md", "utf-8");
|
@ -8,7 +8,6 @@
|
||||
}
|
||||
body {
|
||||
@apply dark:bg-mixed-100 bg-primary-600
|
||||
/* background: linear-gradient(47deg, black, #620072, #120072); */
|
||||
}
|
||||
input {
|
||||
@apply py-2 px-4 rounded-full dark:bg-mixed-200 dark:bg-mixed-600 placeholder:text-dark-500
|
||||
@ -38,4 +37,11 @@
|
||||
.btn-secondary {
|
||||
@apply text-primary-500 py-4 px-6 font-bold text-lg
|
||||
}
|
||||
.p {
|
||||
@apply py-1
|
||||
}
|
||||
|
||||
.poppable {
|
||||
@apply card bg-mixed-300 p-2 rounded-lg transition-opacity data-[visible=true]:z-10 data-[visible=true]:opacity-100 data-[visible=false]:opacity-0 -z-10 max-w-[400px] absolute
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className + " flex min-h-[100vh]"}>
|
||||
<nav className="h-[100vh] p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
|
||||
<nav className="h-[100vh] fixed top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
|
||||
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
|
||||
Tabletop Commander
|
||||
</h1>
|
||||
@ -42,9 +42,10 @@ export default function RootLayout({
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main className="p-8 w-full">
|
||||
<main className="p-8 w-full ml-64">
|
||||
{children}
|
||||
</main>
|
||||
<div id="root-portal"></div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
12
app/page.tsx
12
app/page.tsx
@ -1,3 +1,4 @@
|
||||
import { readMD } from "@/actions/temp";
|
||||
import { TCMD } from "@/components/tcmd";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
@ -16,7 +17,8 @@ export default function Home() {
|
||||
<h2 className="strapline">Tabletop Commander</h2>
|
||||
<h1 className="text-5xl font-bold">How does it work?</h1>
|
||||
</section>
|
||||
<section className="w-full my-6">
|
||||
{
|
||||
/* <section className="w-full my-6">
|
||||
<div className="card">
|
||||
<p>
|
||||
Tabletop Commander (TC) is a rules-and-tools app for tabletop games
|
||||
@ -135,13 +137,11 @@ export default function Home() {
|
||||
will be done. If this makes it to production, tell Emma she forgot to
|
||||
turn the home page into magic
|
||||
</cite>
|
||||
</section>
|
||||
</section> */
|
||||
}
|
||||
<Suspense>
|
||||
<TCMD
|
||||
body={(async () => {
|
||||
"use server";
|
||||
return await readFile("./test.md", "utf-8");
|
||||
})()}
|
||||
body={readMD()}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
|
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, AccordionContent } from "@/lib/accordion";
|
||||
import { Poppable } from "@/lib/poppables/components/poppable";
|
||||
import { createElements } from "@/lib/tcmd";
|
||||
import Link from "next/link";
|
||||
import React, { FC, Fragment, ReactNode, use, useMemo } from "react";
|
||||
@ -12,7 +14,9 @@ export const TCMD: FC<{ body: Promise<string> }> = ({ body }) => {
|
||||
// <pre>{JSON.stringify(elements,null,2)}</pre>
|
||||
// </div>
|
||||
<div>
|
||||
{elements.map(renderBlock)}
|
||||
{elements.map((e, i) => (
|
||||
<Fragment key={"tcmd-block" + i}>{renderBlock(e)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -20,7 +24,9 @@ export const TCMD: FC<{ body: Promise<string> }> = ({ body }) => {
|
||||
const renderBlock = (block: BlockChildren): ReactNode => {
|
||||
switch (block.type) {
|
||||
case "block":
|
||||
return block.children.map(renderBlock);
|
||||
return block.children.map((e, i) => (
|
||||
<Fragment key={"tcmd-block" + i}>{renderBlock(e)}</Fragment>
|
||||
));
|
||||
case "grid":
|
||||
return (
|
||||
<div
|
||||
@ -30,14 +36,36 @@ const renderBlock = (block: BlockChildren): ReactNode => {
|
||||
className="grid grid-cols-dynamic gap-x-8 gap-y-6 mb-6"
|
||||
>
|
||||
{block.children.map((c, i) => (
|
||||
<Fragment key={"block-grid" + c.type + i}>
|
||||
<div key={"block-grid" + c.type + i}>
|
||||
{renderBlock(c)}
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "card":
|
||||
return <div className="card">{block.children.map(renderBlock)}</div>;
|
||||
return (
|
||||
<div className="card">
|
||||
{block.children.map((e, i) => (
|
||||
<Fragment key={"card-block" + i + e.type}>
|
||||
{renderBlock(e)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "accordion":
|
||||
return (
|
||||
<Accordion
|
||||
title={block.metadata.title || "Expand"}
|
||||
>
|
||||
<AccordionContent>
|
||||
{block.children.map((e, i) => (
|
||||
<Fragment key={"accordion" + e.type + "i"}>
|
||||
{renderBlock(e)}
|
||||
</Fragment>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</Accordion>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
renderParagraph(block as ParagraphToken)
|
||||
@ -48,7 +76,15 @@ const renderBlock = (block: BlockChildren): ReactNode => {
|
||||
const renderParagraph = (p: ParagraphToken) => {
|
||||
switch (p.type) {
|
||||
case "p":
|
||||
return <p>{p.content.map(renderToken)}</p>;
|
||||
return (
|
||||
<div className="p">
|
||||
{p.content.map((e, i) => (
|
||||
<Fragment key={"p-paragraph" + i + e.type}>
|
||||
{renderToken(e)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap">
|
||||
@ -57,9 +93,9 @@ const renderParagraph = (p: ParagraphToken) => {
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<p className="bg-red-600 text-white">
|
||||
<div className="p bg-red-600 text-white">
|
||||
Block or paragraph missing implementation: {p.type}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -68,45 +104,49 @@ const renderToken = (t: Token) => {
|
||||
switch (t.type) {
|
||||
case "h1":
|
||||
return (
|
||||
<p
|
||||
<div
|
||||
id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
|
||||
" ",
|
||||
"-",
|
||||
)}
|
||||
className="font-bold text-2xl"
|
||||
className="font-bold text-2xl p"
|
||||
>
|
||||
{renderInlineToken(t.line)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "h2":
|
||||
return (
|
||||
<p
|
||||
<div
|
||||
id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
|
||||
" ",
|
||||
"-",
|
||||
)}
|
||||
className="font-bold text-xl"
|
||||
className="font-bold text-xl p"
|
||||
>
|
||||
{renderInlineToken(t.line)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "h3":
|
||||
return (
|
||||
<p
|
||||
<div
|
||||
id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
|
||||
" ",
|
||||
"-",
|
||||
)}
|
||||
className="font-bold text-lg"
|
||||
className="font-bold text-lg p"
|
||||
>
|
||||
{renderInlineToken(t.line)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "p":
|
||||
return (
|
||||
<p>
|
||||
{t.lines.map(renderToken)}
|
||||
</p>
|
||||
<div className="p">
|
||||
{t.lines.map((e, i) => (
|
||||
<Fragment key={"p-line" + i + e.type}>
|
||||
{renderInlineToken(e.line)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
@ -121,10 +161,10 @@ const renderToken = (t: Token) => {
|
||||
return <li className="list-disc ml-8">{renderInlineToken(t.line)}</li>;
|
||||
default:
|
||||
return (
|
||||
<p className="text-white bg-red-500">
|
||||
<div className="text-white bg-red-500 p">
|
||||
Missing implementation for tcMD element `{(t as { type: string })
|
||||
.type}`
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -132,27 +172,44 @@ const renderToken = (t: Token) => {
|
||||
const renderInlineToken = (l: Line) => {
|
||||
if (typeof l === "string") return l;
|
||||
|
||||
return l.map((token) => {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
return <span>{token.content}</span>;
|
||||
case "bold":
|
||||
return <span className="font-bold">{token.content}</span>;
|
||||
case "anchor":
|
||||
return (
|
||||
<Link className="text-primary-600" href={token.data.href}>
|
||||
{token.content}
|
||||
</Link>
|
||||
);
|
||||
case "image":
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={token.data.src} alt={token.content} />;
|
||||
default:
|
||||
return (
|
||||
<span className="bg-red-500">
|
||||
Inline element not implemented: {token.type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
return l.map((token) => (
|
||||
<Fragment key={"inline_token" + token.content}>
|
||||
{(() => {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
return <span>{token.content}</span>;
|
||||
case "bold":
|
||||
return <span className="font-bold">{token.content}</span>;
|
||||
case "anchor":
|
||||
return (
|
||||
<Link className="text-primary-600" href={token.data.href}>
|
||||
{token.content}
|
||||
</Link>
|
||||
);
|
||||
case "image":
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={token.data.src} alt={token.content} />;
|
||||
case "popover":
|
||||
return (
|
||||
<Poppable
|
||||
content={token.data.popover}
|
||||
preferredAlign="centered"
|
||||
preferredEdge="bottom"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="border-b border-dotted border-white">
|
||||
{token.content}
|
||||
</span>
|
||||
</Poppable>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="bg-red-500">
|
||||
Inline element not implemented: {token.type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</Fragment>
|
||||
));
|
||||
};
|
||||
|
12
hooks/useDebounce.ts
Normal file
12
hooks/useDebounce.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useDebounce = (value: any, delay: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
40
hooks/useInput.ts
Normal file
40
hooks/useInput.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { useState, ChangeEvent } from 'react';
|
||||
|
||||
type InputHookConfig = {
|
||||
disallowSpaces?: boolean;
|
||||
spaceReplacer?: string;
|
||||
}
|
||||
|
||||
export const useInput = <T extends string | number>(initialValue: T, config?: InputHookConfig) => {
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
reset: () => setValue(initialValue),
|
||||
bind: {
|
||||
value: value,
|
||||
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const changed: string | number = typeof initialValue === 'number' ? parseInt(event.target.value) : config?.disallowSpaces ? event.target.value.replace(' ', config.spaceReplacer || '_') : event.target.value;
|
||||
setValue(changed as T);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useCheckbox = (initial: boolean) => {
|
||||
const [value, setValue] = useState(initial);
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
reset: () => setValue(initial),
|
||||
bind: {
|
||||
checked: value,
|
||||
onClick: () => {
|
||||
setValue(v => !v);
|
||||
},
|
||||
readOnly: true
|
||||
}
|
||||
};
|
||||
};
|
126
hooks/useObjectState.ts
Normal file
126
hooks/useObjectState.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import React, { ChangeEvent, useCallback, useState } from "react";
|
||||
import { InputBinder } from "../types/inputBinder";
|
||||
|
||||
type ObjectStateHookConfig = {
|
||||
disallowSpaces?: boolean;
|
||||
spaceReplacer?: string;
|
||||
};
|
||||
|
||||
export const useObjectState = <T extends object>(initial: T) => {
|
||||
const [state, setState] = useState<T>(initial || {} as T);
|
||||
|
||||
const bindProperty = useCallback(
|
||||
<K extends keyof T>(property: K, config?: ObjectStateHookConfig) => ({
|
||||
value: state[property] ?? "",
|
||||
name: property,
|
||||
onChange: (
|
||||
event: ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>,
|
||||
) =>
|
||||
setState((value) => (
|
||||
{
|
||||
...value,
|
||||
[event.target.name]: (
|
||||
(typeof value[property] === "number")
|
||||
? Number(event.target.value) || 0
|
||||
: config?.disallowSpaces
|
||||
? event.target.value.replace(" ", config.spaceReplacer || "_")
|
||||
: event.target.value
|
||||
),
|
||||
}
|
||||
)),
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
const bindPropertyCheck = useCallback(<K extends keyof T>(property: K) => ({
|
||||
checked: !!state[property],
|
||||
name: property,
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) =>
|
||||
setState((value) => ({
|
||||
...value,
|
||||
[event.target.name]: (event.target.checked),
|
||||
})),
|
||||
readOnly: true,
|
||||
}), [state]);
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<T>) => setState((s) => ({ ...s, ...updates })),
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(initial);
|
||||
}, [initial]);
|
||||
|
||||
return {
|
||||
bindProperty,
|
||||
bindPropertyCheck,
|
||||
update,
|
||||
state,
|
||||
setState,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
export const useObjectStateWrapper = <T extends object>(
|
||||
state: T,
|
||||
setState: React.Dispatch<React.SetStateAction<T>>,
|
||||
) => {
|
||||
const bindProperty = useCallback(
|
||||
<K extends keyof T>(
|
||||
property: K,
|
||||
config?: ObjectStateHookConfig,
|
||||
): InputBinder => ({
|
||||
value: state[property]?.toString() ?? "",
|
||||
name: property.toString(),
|
||||
onChange: (
|
||||
event: ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>,
|
||||
) =>
|
||||
setState((value) => (
|
||||
{
|
||||
...value,
|
||||
[event.target.name]: (
|
||||
(typeof value[property] === "number")
|
||||
? Number(event.target.value) || 0
|
||||
: config?.disallowSpaces
|
||||
? event.target.value.replace(" ", config.spaceReplacer || "_")
|
||||
: event.target.value
|
||||
),
|
||||
}
|
||||
)),
|
||||
}),
|
||||
[setState, state],
|
||||
);
|
||||
|
||||
const bindPropertyCheck = useCallback(<K extends keyof T>(property: K) => ({
|
||||
checked: !!state[property],
|
||||
name: property,
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) =>
|
||||
setState((value) => ({
|
||||
...value,
|
||||
[event.target.name]: (event.target.checked),
|
||||
})),
|
||||
readOnly: true,
|
||||
}), [setState, state]);
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<T> | ((arg: T) => Partial<T>)) =>
|
||||
setState((s) => ({
|
||||
...s,
|
||||
...(typeof updates === "function" ? updates(s) : updates),
|
||||
})),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
bindProperty,
|
||||
bindPropertyCheck,
|
||||
update,
|
||||
state,
|
||||
setState,
|
||||
};
|
||||
};
|
10
hooks/useQueryParams.ts
Normal file
10
hooks/useQueryParams.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { QueryParams } from '../utils/queryParams'
|
||||
|
||||
export const useQueryParams = (options?: {clean: boolean}) => {
|
||||
const queryParams = useRef(new QueryParams(options))
|
||||
|
||||
useEffect(() => {
|
||||
console.log(queryParams.current.get('test'))
|
||||
}, [queryParams.current])
|
||||
}
|
19
hooks/useRefCallback.ts
Normal file
19
hooks/useRefCallback.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ReactNode, useCallback, useRef } from 'react'
|
||||
|
||||
export const useRefCallback = <T = ReactNode>() : [T | null, (arg: T) => void] => {
|
||||
const ref = useRef<T | null>(null);
|
||||
const setRef = useCallback((val: T) => {
|
||||
console.log(val);
|
||||
if (ref.current) {
|
||||
// does something?
|
||||
}
|
||||
|
||||
if (val) {
|
||||
// also does something?
|
||||
}
|
||||
|
||||
ref.current = val
|
||||
}, [])
|
||||
|
||||
return [ref.current, setRef]
|
||||
}
|
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)
|
@ -4,8 +4,10 @@ const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
@ -42,6 +44,9 @@ const config: Config = {
|
||||
gridTemplateColumns: {
|
||||
dynamic: "repeat(var(--grid-cols), minmax(0,1fr))",
|
||||
},
|
||||
height: {
|
||||
variable: "var(--v-height)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
31
test.md
31
test.md
@ -4,16 +4,17 @@
|
||||
|
||||
[[
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc
|
||||
mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non
|
||||
enim praesent elementum facilisis leo vel. Orci nulla pellentesque dignissim
|
||||
enim sit amet venenatis. Eu feugiat pretium nibh ipsum. Gravida dictum fusce ut
|
||||
placerat orci nulla pellentesque. Tincidunt vitae semper quis lectus nulla at
|
||||
volutpat diam ut. Proin sed libero enim sed faucibus turpis in eu mi. Dui sapien
|
||||
eget mi proin sed libero enim sed faucibus. Felis donec et odio pellentesque
|
||||
diam volutpat commodo sed egestas. Massa tincidunt dui ut ornare lectus sit amet
|
||||
est placerat. Auctor urna nunc id cursus metus aliquam eleifend.
|
||||
Lorem ^[ipsum](This is a popover test) dolor sit amet, consectetur adipiscing
|
||||
elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
Sollicitudin tempor id eu nisl nunc mi ipsum faucibus vitae. Lobortis elementum
|
||||
nibh tellus molestie nunc. Purus non enim praesent elementum facilisis leo vel.
|
||||
Orci nulla pellentesque dignissim enim sit amet venenatis. Eu feugiat pretium
|
||||
nibh ipsum. Gravida dictum fusce ut placerat orci nulla pellentesque. Tincidunt
|
||||
vitae semper quis lectus nulla at volutpat diam ut. Proin sed libero enim sed
|
||||
faucibus turpis in eu mi. Dui sapien eget mi proin sed libero enim sed faucibus.
|
||||
Felis donec et odio pellentesque diam volutpat commodo sed egestas. Massa
|
||||
tincidunt dui ut ornare lectus sit amet est placerat. Auctor urna nunc id cursus
|
||||
metus aliquam eleifend.
|
||||
|
||||
- Lorem ipsum dolor sit amet,
|
||||
- consectetur adipiscing elit, bananana banana ban anana anaba bananananana
|
||||
@ -74,6 +75,16 @@ const blockTokens: {
|
||||
|
||||
[[
|
||||
|
||||
[accordion this is the title]
|
||||
|
||||
this is the test of a single accordion
|
||||
|
||||
[/accordion]
|
||||
|
||||
]]
|
||||
|
||||
[[
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc
|
||||
mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non
|
||||
|
4
types.d.ts
vendored
4
types.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
type InlineToken = {
|
||||
type: "text" | "bold" | "anchor" | "image";
|
||||
type: "text" | "bold" | "anchor" | "image" | "popover";
|
||||
content: string;
|
||||
data?: any;
|
||||
};
|
||||
@ -42,7 +42,7 @@ type MultilineCfg = {
|
||||
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
|
||||
|
||||
type BlockToken = {
|
||||
type: "block" | "grid" | "card";
|
||||
type: "block" | "grid" | "card" | "accordion";
|
||||
metadata: any;
|
||||
children: BlockChildren[];
|
||||
parent?: string;
|
||||
|
Loading…
x
Reference in New Issue
Block a user