feat: Added interface options
This commit is contained in:
parent
4739b89933
commit
2f32cbc730
25 changed files with 327 additions and 74 deletions
|
|
@ -3,6 +3,7 @@ import classNames from 'classnames';
|
|||
import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { useLocalSetting } from '../scripts/utils';
|
||||
|
||||
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
|
||||
|
||||
|
|
@ -28,8 +29,13 @@ export function AnimatedBackground (data: {
|
|||
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
|
||||
}, [data.backgroundUrl]);
|
||||
|
||||
const finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
|
||||
const blur = localStorage.getItem('background-blur') !== "false";
|
||||
let finalBackgroundUrl;
|
||||
try
|
||||
{
|
||||
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
|
||||
} catch { }
|
||||
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
if (blur)
|
||||
{
|
||||
if (!finalBackgroundUrl?.searchParams.has('blur'))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function ContextList (data: { options?: DialogEntry[]; className?: string
|
|||
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" content="Close" />}
|
||||
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
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 ? handleAction : undefined,
|
||||
onEnterPress: data.shortcuts ? undefined : handleAction,
|
||||
onFocus: handleFocus,
|
||||
trackChildren: typeof data.content !== 'string'
|
||||
});
|
||||
|
|
@ -52,9 +52,9 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
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",
|
||||
colors[data.type],
|
||||
classNames({ "font-semibold": focused || hasFocusedChild }),
|
||||
data.className)}>
|
||||
data.className,
|
||||
colors[data.type])}>
|
||||
{data.icon}
|
||||
{data.content}
|
||||
</div>
|
||||
|
|
@ -75,7 +75,8 @@ export interface DialogEntry
|
|||
export function ContextDialog (data: {
|
||||
id: string,
|
||||
children: any | any[],
|
||||
open: boolean, close: () => void;
|
||||
open: boolean,
|
||||
close: () => void;
|
||||
className?: string;
|
||||
preferredChildFocusKey?: string;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { HardDrive } from "lucide-react";
|
|||
import { JSX } from "react";
|
||||
import { GameCardFocusHandler } from "./GameCard";
|
||||
import { gameQuery } from "../scripts/queries";
|
||||
import { useLocalSetting } from "../scripts/utils";
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
|
|
@ -30,6 +31,7 @@ export function GameList (data: GameListParams)
|
|||
});
|
||||
const navigator = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
|
||||
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) =>
|
||||
{
|
||||
|
|
@ -40,10 +42,9 @@ export function GameList (data: GameListParams)
|
|||
{
|
||||
const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`);
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`);
|
||||
const previewUrl = localStorage.getItem('background-blur') !== "false" ? coverUrl : screenshotUrl;
|
||||
const previewUrl = blur ? coverUrl : screenshotUrl;
|
||||
previewUrl.searchParams.delete('ts');
|
||||
data.setBackground?.(previewUrl.href);
|
||||
//queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id));
|
||||
} catch
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
previewUrl: "",
|
||||
badges,
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + i}/100/100.webp?blur=10`,
|
||||
g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
|
|
|
|||
59
src/mainview/components/options/LocalOption.tsx
Normal file
59
src/mainview/components/options/LocalOption.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { HTMLInputTypeAttribute, JSX } from "react";
|
||||
import { LocalSettingsSchema, LocalSettingsType } from "../../../shared/constants";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { OptionDropdown } from "./OptionDropdown";
|
||||
|
||||
export function LocalOption (data: {
|
||||
label: string;
|
||||
id: keyof LocalSettingsType;
|
||||
type: HTMLInputTypeAttribute | 'dropdown';
|
||||
placeholder?: string;
|
||||
values?: string[];
|
||||
icon?: JSX.Element;
|
||||
children?: any;
|
||||
})
|
||||
{
|
||||
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) });
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
defaultValue={localValue}
|
||||
onChange={(v) =>
|
||||
{
|
||||
if (data.type === 'checkbox')
|
||||
{
|
||||
setLocalValue(v);
|
||||
} else
|
||||
{
|
||||
setLocalValue(v);
|
||||
}
|
||||
}}
|
||||
value={localValue} />}
|
||||
{data.type !== 'dropdown' && <OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
defaultValue={localValue}
|
||||
onChange={(v) =>
|
||||
{
|
||||
if (data.type === 'checkbox')
|
||||
{
|
||||
setLocalValue(v);
|
||||
} else
|
||||
{
|
||||
setLocalValue(v);
|
||||
}
|
||||
}}
|
||||
value={localValue}
|
||||
/>}
|
||||
{data.children}
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
67
src/mainview/components/options/OptionDropdown.tsx
Normal file
67
src/mainview/components/options/OptionDropdown.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import classNames from "classnames";
|
||||
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useOptionContext } from "./OptionSpace";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { systemApi } from "../../scripts/clientApi";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function OptionDropdown (data: {
|
||||
name: string;
|
||||
type: HTMLInputTypeAttribute;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
value?: string;
|
||||
values: string[];
|
||||
defaultValue?: string | boolean;
|
||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChange?: (value: any) => void;
|
||||
})
|
||||
{
|
||||
const [open, setOpen] = useState(false);
|
||||
const handlePress = () =>
|
||||
{
|
||||
setOpen(true);
|
||||
};
|
||||
const handleClose = () => setOpen(false);
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: handlePress
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress: handlePress,
|
||||
});
|
||||
|
||||
const valueIndex = data.value ? data.values?.indexOf(data.value) : -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
classNames({ "[&_button]:not-focus:ring-7 [&_button]:not-focus:ring-accent": focused }))}>
|
||||
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>}
|
||||
<button onClick={() =>
|
||||
{
|
||||
console.log("Open");
|
||||
setOpen(true);
|
||||
}} className={classNames('btn input rounded-full cursor-pointer grow', { "bg-base-200": !focused })}>{data.value}<ChevronDown /></button>
|
||||
</label>
|
||||
{open && <ContextDialog id={`${data.name}-context`} open={true} close={handleClose}>
|
||||
<ContextList options={data.values.map((v, i) => ({
|
||||
content: v,
|
||||
id: String(i),
|
||||
type: 'primary',
|
||||
action: () =>
|
||||
{
|
||||
data.onChange?.(v);
|
||||
setOpen(false);
|
||||
}
|
||||
} satisfies DialogEntry))} />
|
||||
</ContextDialog>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge";
|
|||
import { useOptionContext } from "./OptionSpace";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { systemApi } from "../../scripts/clientApi";
|
||||
import { Check, CheckIcon, X } from "lucide-react";
|
||||
|
||||
export function OptionInput (data: {
|
||||
name: string;
|
||||
|
|
@ -12,24 +13,28 @@ export function OptionInput (data: {
|
|||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
defaultValue?: string | boolean;
|
||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onChange?: (value: any) => void;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: () =>
|
||||
const handlePress = () =>
|
||||
{
|
||||
if (data.type === 'checkbox')
|
||||
{
|
||||
inputRef.current?.click();
|
||||
} else
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: handlePress
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress ()
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
onOptionEnterPress: handlePress,
|
||||
});
|
||||
const handleFocus = () =>
|
||||
{
|
||||
|
|
@ -44,32 +49,60 @@ export function OptionInput (data: {
|
|||
Height: rect.height
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
classNames({ "[&_input]:not-focus:ring-7 [&_input]:not-focus:ring-accent": focused }))}>
|
||||
classNames({ "[&_.focus-target]:not-focus:ring-7 [&_.focus-target]:not-focus:ring-accent": focused, "pl-1": data.type === 'checkbox' }))}>
|
||||
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>}
|
||||
<input
|
||||
{data.type !== 'checkbox' && <input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
data-focus={"input"}
|
||||
name={data.name}
|
||||
value={data.value}
|
||||
defaultValue={data.defaultValue}
|
||||
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
|
||||
type={data.type}
|
||||
autoComplete={data.autocomplete}
|
||||
onFocus={handleFocus}
|
||||
placeholder={data.placeholder}
|
||||
onChange={data.onChange}
|
||||
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
|
||||
onBlur={data.onBlur}
|
||||
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
|
||||
className={twMerge(
|
||||
"input grow rounded-full ring-primary-content focus:ring-7",
|
||||
data.className,
|
||||
"focus-target text-base-content",
|
||||
"input grow rounded-full ring-primary-content focus:ring-7", classNames({
|
||||
"bg-base-200": !focused
|
||||
}),
|
||||
data.className
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
{data.type === 'checkbox' && <div className={classNames("toggle focus-target toggle-primary toggle-xl border-base-content/30 rounded-full before:rounded-full text-base-content", {
|
||||
"bg-base-200": !focused,
|
||||
"border-0": focused,
|
||||
})}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
name={data.name}
|
||||
value={data.value}
|
||||
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
|
||||
type={data.type}
|
||||
autoComplete={data.autocomplete}
|
||||
onFocus={handleFocus}
|
||||
placeholder={data.placeholder}
|
||||
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
|
||||
onBlur={data.onBlur}
|
||||
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
|
||||
className={twMerge(
|
||||
data.className
|
||||
)}
|
||||
/>
|
||||
<X />
|
||||
<CheckIcon />
|
||||
</div>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ export function OptionSpace (data: {
|
|||
className?: string;
|
||||
focusable?: boolean;
|
||||
children?: any | any[];
|
||||
label?: string | JSX.Element;
|
||||
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
|
||||
saveLastFocusedChild?: boolean;
|
||||
})
|
||||
{
|
||||
|
|
@ -62,32 +62,37 @@ export function OptionSpace (data: {
|
|||
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
||||
},
|
||||
});
|
||||
let labelElement: any = data.label;
|
||||
if (data.label instanceof Function)
|
||||
{
|
||||
labelElement = data.label(focused);
|
||||
} else if (typeof data.label === 'string')
|
||||
{
|
||||
labelElement = <label
|
||||
className={classNames({
|
||||
"font-semibold": focused,
|
||||
})}
|
||||
>
|
||||
{data.label}
|
||||
</label>;
|
||||
}
|
||||
|
||||
return (<FocusContext value={focusKey}>
|
||||
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
|
||||
<li
|
||||
ref={ref}
|
||||
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! portrait:rounded-3xl landscape:rounded-full bg-base-content/1", classNames(
|
||||
{
|
||||
"text-primary-content bg-primary ": focused || hasFocusedChild,
|
||||
}),
|
||||
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5",
|
||||
classNames(
|
||||
{
|
||||
"bg-base-300": focused || hasFocusedChild,
|
||||
}),
|
||||
data.className,
|
||||
)}
|
||||
>
|
||||
<div className="label flex-1 md:text-lg pr-4">
|
||||
{typeof data.label === "string" ? (
|
||||
<label
|
||||
className={classNames({
|
||||
"text-primary-content font-semibold": focused,
|
||||
})}
|
||||
>
|
||||
{data.label}
|
||||
</label>
|
||||
) : (
|
||||
data.label
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow justify-end-safe">
|
||||
{!!labelElement && <div className="flex gap-2 items-center flex-1 md:text-lg pr-4">
|
||||
{labelElement}
|
||||
</div>}
|
||||
<div className="flex flex-1 justify-end-safe">
|
||||
{data.children}
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
onBlur={handleInputBlur}
|
||||
onChange={(e) =>
|
||||
{
|
||||
data.setLocalValue(e.currentTarget.value);
|
||||
data.setLocalValue(e);
|
||||
}}
|
||||
value={data.localValue}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { HTMLInputTypeAttribute, JSX } from "react";
|
|||
import { OptionInput } from "./OptionInput";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import classNames from "classnames";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
// export useFieldContext for use in your custom components
|
||||
export const { fieldContext, formContext, useFieldContext } =
|
||||
|
|
@ -30,7 +29,7 @@ function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; l
|
|||
name={field.name}
|
||||
value={field.state.value}
|
||||
type={data.type}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onChange={v => field.handleChange(v)}
|
||||
placeholder={data.placeholder}
|
||||
className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ export function SettingsOption (data: {
|
|||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
onBlur={handleSave}
|
||||
onChange={(e) =>
|
||||
onChange={(v) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setLocalValue(v);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue