You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
5.8 KiB
206 lines
5.8 KiB
import { Icon, Icons } from "@/components/Icon"; |
|
import { Spinner } from "@/components/layout/Spinner"; |
|
import { ProgressRing } from "@/components/layout/ProgressRing"; |
|
import { createRef, useEffect, useRef } from "react"; |
|
|
|
interface PopoutListEntryBaseTypes { |
|
active?: boolean; |
|
children: React.ReactNode; |
|
onClick?: () => void; |
|
isOnDarkBackground?: boolean; |
|
} |
|
|
|
interface PopoutListEntryTypes extends PopoutListEntryBaseTypes { |
|
percentageCompleted?: number; |
|
loading?: boolean; |
|
errored?: boolean; |
|
} |
|
|
|
interface PopoutListEntryRootTypes extends PopoutListEntryBaseTypes { |
|
right?: React.ReactNode; |
|
noChevron?: boolean; |
|
} |
|
|
|
interface PopoutListActionTypes extends PopoutListEntryBaseTypes { |
|
icon?: Icons; |
|
right?: React.ReactNode; |
|
download?: string; |
|
href?: string; |
|
noChevron?: boolean; |
|
} |
|
|
|
interface ScrollToActiveProps { |
|
children: React.ReactNode; |
|
className?: string; |
|
} |
|
|
|
interface PopoutSectionProps { |
|
children?: React.ReactNode; |
|
className?: string; |
|
} |
|
|
|
export function ScrollToActive(props: ScrollToActiveProps) { |
|
const ref = createRef<HTMLDivElement>(); |
|
const inited = useRef<boolean>(false); |
|
|
|
// Scroll to "active" child on first load (AKA mount except React dumb) |
|
useEffect(() => { |
|
if (inited.current) return; |
|
if (!ref.current) return; |
|
|
|
const el = ref.current as HTMLDivElement; |
|
|
|
// Find nearest scroll container, or self |
|
const wrapper: HTMLDivElement | null = el.classList.contains( |
|
"overflow-y-auto" |
|
) |
|
? el |
|
: el.closest(".overflow-y-auto"); |
|
|
|
const active: HTMLDivElement | null | undefined = |
|
wrapper?.querySelector(".active"); |
|
|
|
if (wrapper && active) { |
|
let activeYPositionCentered = 0; |
|
const setActiveYPositionCentered = () => { |
|
activeYPositionCentered = |
|
active.getBoundingClientRect().top - |
|
wrapper.getBoundingClientRect().top + |
|
active.offsetHeight / 2; |
|
}; |
|
setActiveYPositionCentered(); |
|
|
|
if (activeYPositionCentered >= wrapper.offsetHeight / 2) { |
|
// Check if the active element is below the vertical center line, then scroll it into center |
|
wrapper.scrollTo({ |
|
top: activeYPositionCentered - wrapper.offsetHeight / 2, |
|
}); |
|
} |
|
|
|
setActiveYPositionCentered(); |
|
if (activeYPositionCentered > wrapper.offsetHeight / 2) { |
|
// If the element is over the vertical center line, scroll to the end |
|
wrapper.scrollTo({ |
|
top: wrapper.scrollHeight, |
|
}); |
|
} |
|
} |
|
inited.current = true; |
|
}, [ref]); |
|
|
|
return ( |
|
<div className={props.className} ref={ref}> |
|
{props.children} |
|
</div> |
|
); |
|
} |
|
|
|
export function PopoutSection(props: PopoutSectionProps) { |
|
return ( |
|
<ScrollToActive className={["p-5", props.className || ""].join(" ")}> |
|
{props.children} |
|
</ScrollToActive> |
|
); |
|
} |
|
|
|
export function PopoutListEntryBase(props: PopoutListEntryRootTypes) { |
|
const bg = props.isOnDarkBackground ? "bg-ash-200" : "bg-ash-400"; |
|
const hover = props.isOnDarkBackground |
|
? "hover:bg-ash-200" |
|
: "hover:bg-ash-400"; |
|
|
|
return ( |
|
<div |
|
className={[ |
|
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150", |
|
hover, |
|
props.active |
|
? `${bg} active text-white outline-denim-700` |
|
: "text-denim-700 hover:text-white", |
|
].join(" ")} |
|
onClick={props.onClick} |
|
> |
|
{props.active && ( |
|
<div className="absolute left-0 h-8 w-0.5 bg-bink-500" /> |
|
)} |
|
<span className="truncate">{props.children}</span> |
|
<div className="relative min-h-[1rem] min-w-[1rem]"> |
|
{!props.noChevron && ( |
|
<Icon |
|
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100" |
|
icon={Icons.CHEVRON_RIGHT} |
|
/> |
|
)} |
|
{props.right} |
|
</div> |
|
</div> |
|
); |
|
} |
|
|
|
export function PopoutListEntry(props: PopoutListEntryTypes) { |
|
return ( |
|
<PopoutListEntryBase |
|
isOnDarkBackground={props.isOnDarkBackground} |
|
active={props.active} |
|
onClick={props.onClick} |
|
noChevron={props.loading || props.errored} |
|
right={ |
|
<> |
|
{props.errored && ( |
|
<Icon |
|
icon={Icons.WARNING} |
|
className="absolute inset-0 text-rose-400" |
|
/> |
|
)} |
|
{props.loading && !props.errored && ( |
|
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" /> |
|
)} |
|
{props.percentageCompleted && !props.loading && !props.errored ? ( |
|
<ProgressRing |
|
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0" |
|
backingRingClassname="stroke-ash-500" |
|
percentage={ |
|
props.percentageCompleted > 90 ? 100 : props.percentageCompleted |
|
} |
|
/> |
|
) : ( |
|
"" |
|
)} |
|
</> |
|
} |
|
> |
|
{props.children} |
|
</PopoutListEntryBase> |
|
); |
|
} |
|
|
|
export function PopoutListAction(props: PopoutListActionTypes) { |
|
const entry = ( |
|
<PopoutListEntryBase |
|
active={props.active} |
|
isOnDarkBackground={props.isOnDarkBackground} |
|
right={props.right} |
|
onClick={props.href ? undefined : props.onClick} |
|
noChevron={props.noChevron} |
|
> |
|
<div className="flex items-center space-x-3"> |
|
{props.icon ? <Icon className="text-xl" icon={props.icon} /> : null} |
|
<div>{props.children}</div> |
|
</div> |
|
</PopoutListEntryBase> |
|
); |
|
|
|
return props.href ? ( |
|
<a |
|
href={props.href ? props.href : undefined} |
|
rel="noreferrer" |
|
target="_blank" |
|
download={props.download ? props.download : undefined} |
|
onClick={props.onClick} |
|
> |
|
{entry} |
|
</a> |
|
) : ( |
|
entry |
|
); |
|
}
|
|
|