131 lines
No EOL
5.1 KiB
TypeScript
131 lines
No EOL
5.1 KiB
TypeScript
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
|
import classNames from "classnames";
|
|
import { createContext, JSX, useContext, useEffect } from "react";
|
|
import { twMerge } from "tailwind-merge";
|
|
import { X } from "lucide-react";
|
|
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
|
|
|
const ContextDialogContext = createContext({} as {
|
|
close: () => void,
|
|
id: string;
|
|
});
|
|
|
|
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
|
{
|
|
const context = useContext(ContextDialogContext);
|
|
return <ul className={twMerge("list", data.className)}>
|
|
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
|
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
|
|
</ul>;
|
|
}
|
|
|
|
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; })
|
|
{
|
|
const context = useContext(ContextDialogContext);
|
|
const handleFocus = () =>
|
|
{
|
|
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
data.onFocus?.();
|
|
};
|
|
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
|
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
|
focusKey: `${context.id}-list-option-${data.id}`,
|
|
onEnterPress: data.shortcuts ? undefined : handleAction,
|
|
onFocus: handleFocus,
|
|
trackChildren: typeof data.content !== 'string'
|
|
});
|
|
const colors = {
|
|
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
|
|
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
|
|
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
|
|
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
|
|
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
|
|
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
|
|
};
|
|
if (data.shortcuts)
|
|
{
|
|
useShortcuts(focusKey, () => data.shortcuts!, [data.shortcuts]);
|
|
}
|
|
return <li ref={ref}
|
|
onClick={handleAction}
|
|
className={
|
|
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
|
|
<FocusContext value={focusKey}>
|
|
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2",
|
|
classNames({ "font-semibold": focused || hasFocusedChild }),
|
|
data.className,
|
|
colors[data.type])}>
|
|
{data.icon}
|
|
{data.content}
|
|
</div>
|
|
</FocusContext>
|
|
</li>;
|
|
}
|
|
|
|
export interface DialogEntry
|
|
{
|
|
id: string,
|
|
content: string | JSX.Element;
|
|
icon?: string | JSX.Element;
|
|
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
|
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
|
shortcuts?: Shortcut[];
|
|
}
|
|
|
|
export function ContextDialog (data: {
|
|
id: string,
|
|
children: any | any[],
|
|
open: boolean,
|
|
close: () => void;
|
|
className?: string;
|
|
preferredChildFocusKey?: string;
|
|
})
|
|
{
|
|
const { ref, focusKey, focusSelf } = useFocusable({
|
|
focusable: data.open,
|
|
focusKey: `${data.id}-context-dialog`,
|
|
isFocusBoundary: true,
|
|
preferredChildFocusKey: data.preferredChildFocusKey
|
|
});
|
|
useEffect(() =>
|
|
{
|
|
if (data.open)
|
|
{
|
|
focusSelf();
|
|
}
|
|
}, [data.open]);
|
|
|
|
useShortcuts(focusKey, () => data.open ? [{
|
|
label: "Close",
|
|
button: GamePadButtonCode.B,
|
|
action: () =>
|
|
{
|
|
data.close();
|
|
}
|
|
}] : [], [data.open]);
|
|
|
|
return <dialog ref={ref} open={data.open} closedby="any" className={
|
|
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
|
classNames({ "opacity-0": !data.open }))
|
|
}
|
|
onClick={() =>
|
|
{
|
|
if (data.open) data.close();
|
|
}}>
|
|
<FocusContext value={focusKey}>
|
|
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
|
<div
|
|
className={twMerge(
|
|
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",
|
|
data.open ? "animate-scale-delayed" : "opacity-0",
|
|
data.className)
|
|
}
|
|
style={{ backdropFilter: 'md:blur(24px)' }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{data.children}
|
|
</div>
|
|
</ContextDialogContext>
|
|
</FocusContext>
|
|
</dialog>;
|
|
} |