feat: implemented haptics

feat: Implemented a select menu
fix: Only used audio clips compile
This commit is contained in:
Simeon Radivoev 2026-04-07 15:28:56 +03:00
parent 02a4f2c9a9
commit 54dd9256e3
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
51 changed files with 580 additions and 466 deletions

View file

@ -8,6 +8,8 @@ import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
import { GamepadButtonEvent } from "../scripts/gamepads";
export interface GameMetaExtra extends GameMeta
{
@ -24,10 +26,11 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
preview = data.game.previewUrl;
}
const handleAction = () =>
const handleAction = (e?: Event) =>
{
data.game.onSelect?.();
data.onAction?.();
oneShot('click');
};
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);

View file

@ -3,9 +3,9 @@ import { StickyHeaderUI } from './Header';
import { GameList } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { FloatingShortcuts } from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './CardElement';
import { HandleGoBack } from '../scripts/utils';
@ -13,6 +13,7 @@ import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { gameQuery } from '../scripts/queries/romm';
import { useRouter } from '@tanstack/react-router';
import SelectMenu from './SelectMenu';
export interface CollectionsDetailParams
{
@ -43,8 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]);
const { shortcuts } = useShortcutContext();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
const handleScroll: GameCardFocusHandler = (cardId, node, details) =>
{
@ -83,9 +83,10 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div>
{data.footer}
</div>
<Shortcuts shortcuts={shortcuts} />
<FloatingShortcuts />
</footer>
</div>
<SelectMenu rootFocusKey={focusKey} />
</FocusContext>
);
}

View file

@ -7,6 +7,7 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
import { ContextDialogContext } from "../scripts/contexts";
import { FOCUS_KEYS } from "../scripts/types";
import { oneShot } from "../scripts/audio/audio";
import { oneShotRumble } from "../scripts/gamepads";
export function ContextList (data: {
options?: DialogEntry[];
@ -18,7 +19,7 @@ export function ContextList (data: {
const context = useContext(ContextDialogContext);
return <ul className={twMerge("list gap-1", data.className)}>
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
<div className="divider m-0 "></div>
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>;
}
@ -85,9 +86,9 @@ export interface DialogEntry
shortcuts?: Shortcut[];
}
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; })
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; defaultOpen?: boolean; backdropClassName?: string; })
{
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(data.defaultOpen ?? false);
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
const handleClose = (value: boolean, newSourceFocusKey?: string) =>
{
@ -111,7 +112,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
}
};
const dialog = <ContextDialog id={id} open={open} close={handleClose} className={data.className} preferredChildFocusKey={data.preferredChildFocusKey}>
const dialog = <ContextDialog id={id} open={open} close={handleClose} backdropClassName={data.backdropClassName} className={data.className} preferredChildFocusKey={data.preferredChildFocusKey}>
{data.content}
</ContextDialog>;
return {
@ -127,12 +128,13 @@ export function ContextDialog (data: {
open: boolean,
close: (open: boolean) => void;
className?: string;
backdropClassName?: string;
preferredChildFocusKey?: string;
})
{
const { ref, focusKey, focusSelf } = useFocusable({
focusable: data.open,
focusKey: `${data.id}-context-dialog`,
focusKey: FOCUS_KEYS.CONTEXT_DIALOG(data.id),
isFocusBoundary: true,
saveLastFocusedChild: !data.preferredChildFocusKey,
preferredChildFocusKey: data.preferredChildFocusKey
@ -148,6 +150,7 @@ export function ContextDialog (data: {
{
focusSelf({ instant: true });
oneShot('openContext');
oneShotRumble('openContext', { all: true });
}
}, [data.open]);
@ -159,7 +162,7 @@ export function ContextDialog (data: {
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }))
classNames({ "opacity-0": !data.open }), data.backdropClassName)
}
onClick={handleClose}>
<FocusContext value={focusKey}>

View file

@ -1,7 +1,7 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import Shortcuts from "./Shortcuts";
import { FloatingShortcuts } from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { ErrorComponentProps, useRouter } from "@tanstack/react-router";
@ -12,7 +12,6 @@ export default function Error (data: ErrorComponentProps)
const router = useRouter();
const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf({ instant: true }); }, []);
@ -30,7 +29,7 @@ export default function Error (data: ErrorComponentProps)
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="mobile:hidden bg-dots"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
<FloatingShortcuts />
</FocusContext>
</div>;
}

View file

@ -51,7 +51,7 @@ export default function FocusDots (data: {
{
const focused = em === focusedKey;
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/60 transition-all", classNames({
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
}))}></button>;
});
@ -69,7 +69,7 @@ export default function FocusDots (data: {
}
}, [data.elements, data.scrollElement?.current]);
return <div className="divider opacity-20">
return <div className="divider opacity-40">
<div className="flex gap-2 py-6 justify-center items-center h-3">{elements}</div>
</div>;
}

View file

@ -1,10 +1,10 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { useRouter } from "@tanstack/react-router";
import { FloatingShortcuts } from "./Shortcuts";
export default function NotFound ()
{
@ -12,7 +12,6 @@ export default function NotFound ()
const router = useRouter();
const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf({ instant: true }); }, []);
@ -27,7 +26,7 @@ export default function NotFound ()
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="mobile:hidden bg-dots"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
<FloatingShortcuts />
</FocusContext>
</div>;
}

View file

@ -0,0 +1,106 @@
import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi";
import { FOCUS_KEYS } from "../scripts/types";
export default function SelectMenu (data: { rootFocusKey: string; })
{
const navigate = useNavigate();
const routeState = useRouterState();
const matchRoute = useMatchRoute();
const options: DialogEntry[] = [
{
content: "Home",
icon: <Gamepad2 />,
action (ctx)
{
setOpen(false);
navigate({ to: "/" });
},
selected: !!matchRoute({ to: '/' }),
type: "primary",
id: "home-m"
},
{
content: "Library",
icon: <Gamepad2 />,
action (ctx)
{
setOpen(false);
navigate({ to: "/games" });
},
selected: !!matchRoute({ to: '/games' }),
type: "secondary",
id: "library-m"
},
{
content: "Store",
icon: <Store />,
action (ctx)
{
setOpen(false);
navigate({ to: "/store/tab" });
},
selected: !!matchRoute({ to: '/store/tab' }),
type: "info",
id: "store-m"
},
{
content: "Settings",
icon: <Settings />,
action (ctx)
{
setOpen(false);
navigate({ to: "/settings/accounts" });
},
selected: !!matchRoute({ to: '/settings/accounts' }),
type: "accent",
id: "settings-m"
},
{
content: "Reload",
icon: <RefreshCcw />,
action (ctx)
{
setOpen(false);
navigation.reload();
},
type: "accent",
id: "reload-m"
},
{
content: "Quit",
icon: <DoorOpen />,
action (ctx)
{
systemApi.api.system.exit.post();
},
type: 'error',
id: "quit-m"
}
];
const { dialog, setOpen, open } = useContextDialog('select-menu', {
content: <ContextList showCloseButton={false} options={options} />,
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none',
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
});
useShortcuts(data.rootFocusKey, () => [{
label: "Menu", side: 'left', button: GamePadButtonCode.Select, action (e)
{
if (open)
{
setOpen(false);
} else
{
setOpen(true, getCurrentFocusKey());
}
},
}], [open]);
return <>{dialog}</>;
}

View file

@ -1,9 +1,16 @@
import { useContext } from 'react';
import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts';
import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
import { ShortcutsContext } from '../scripts/contexts';
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
export function FloatingShortcuts ()
{
return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>;
}
export default function Shortcuts (data: { centerElement?: any; })
{
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
@ -47,15 +54,28 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
const { control } = useActiveControl();
const showKeyboard = control === 'keyboard' || control === 'mouse';
const { shortcuts } = useShortcutContext();
return (
<div className="flex gap-2 z-1000" style={{ viewTimelineName: "shortcuts" }}>
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
<>
<div className="flex gap-2 pointer-events-auto">
{shortcuts?.filter(s => !!s.label && s.side === 'left').map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
{data.centerElement}
<div className="flex gap-2 pointer-events-auto">
{shortcuts?.filter(s => !!s.label && s.side !== 'left').map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
</>
);
}

View file

@ -21,7 +21,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
},
onSuccess (data, { source, id }, onMutateResult, context)
{
router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true });
router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
},
});
const ws = useRef<{ send: (data: string) => void; }>(undefined);
@ -108,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(cmd.command);
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true });
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
} else
{
playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id });

View file

@ -9,13 +9,18 @@ export function LocalOption (data: {
label: string;
id: keyof LocalSettingsType;
type: HTMLInputTypeAttribute | 'dropdown';
min?: number;
max?: number;
step?: number;
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)) });
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 id={`${data.id}-space`} label={data.label}>
@ -25,30 +30,21 @@ export function LocalOption (data: {
defaultValue={localValue}
onChange={(v) =>
{
if (data.type === 'checkbox')
{
setLocalValue(v);
} else
{
setLocalValue(v);
}
setLocalValue(v);
}}
value={localValue} />}
{data.type !== 'dropdown' && <OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
min={data.min}
max={data.max}
step={data.step}
placeholder={data.placeholder}
defaultValue={localValue}
onChange={(v) =>
{
if (data.type === 'checkbox')
{
setLocalValue(v);
} else
{
setLocalValue(v);
}
setLocalValue(v);
}}
value={localValue}
/>}

View file

@ -1,10 +1,11 @@
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { CheckIcon, X } from "lucide-react";
import { oneShot } from "@/mainview/scripts/audio/audio";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
export function OptionInput (data: {
name: string;
@ -12,11 +13,14 @@ export function OptionInput (data: {
className?: string;
placeholder?: string;
icon?: JSX.Element;
value?: string | boolean;
defaultValue?: string | boolean;
value?: string | boolean | number;
min?: number;
max?: number;
step?: number;
defaultValue?: string | boolean | number;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: (value: any) => void;
onChange?: (value: string | number | boolean) => void;
})
{
const handlePress = () =>
@ -30,16 +34,74 @@ export function OptionInput (data: {
}
oneShot('click');
};
const { ref } = useFocusable({
focusKey: data.name, onEnterPress: handlePress
});
const [inputFocused, setInputFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { ref, focusKey } = useFocusable({
focusKey: data.name,
onEnterPress: handlePress,
onBlur: () => inputRef.current?.blur()
});
const option = useOptionContext({
onOptionEnterPress: handlePress,
});
const handleFocus = () =>
useEffect(() =>
{
if (data.type === 'range')
{
option.setFocusBoundary(inputFocused);
option.setFocusBoundaryDirections(['left', 'right']);
}
}, [inputFocused, option, data.type]);
useShortcuts(focusKey, () =>
{
const shortcuts: Shortcut[] = [];
if (inputFocused && data.type === 'range')
{
shortcuts.push(
{
label: "Decrease",
button: GamePadButtonCode.Left,
action ()
{
if (!inputRef.current) return;
inputRef.current?.stepDown();
data.onChange?.(inputRef.current.valueAsNumber);
}
},
{
label: "Increase",
button: GamePadButtonCode.Right,
action (e)
{
if (!inputRef.current) return;
inputRef.current?.stepUp();
data.onChange?.(inputRef.current.valueAsNumber);
}
}
);
}
if (inputFocused)
{
shortcuts.push({
label: "Unfocus",
button: GamePadButtonCode.B,
action (e)
{
inputRef.current?.blur();
}
});
}
return shortcuts;
}, [inputFocused, data.type]);
const handleInputFocus = () =>
{
option.focus();
setInputFocused(true);
if (inputRef.current)
{
var rect = inputRef.current?.getBoundingClientRect();
@ -52,25 +114,47 @@ export function OptionInput (data: {
}
};
const handleInputBlur = (e: any) =>
{
data.onBlur?.(e);
setInputFocused(false);
};
return (
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
{data.type !== 'checkbox' && <input
ref={inputRef}
id={data.name}
min={data.min}
max={data.max}
step={data.step}
data-focus={"input"}
name={data.name}
value={String(data.value)}
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
type={data.type}
autoComplete={data.autocomplete}
onFocus={handleFocus}
onFocus={handleInputFocus}
placeholder={data.placeholder}
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
onBlur={data.onBlur}
onChange={e =>
{
if (typeof data.defaultValue === 'boolean')
{
data.onChange?.(e.target.checked);
} else if (data.type === 'range')
{
data.onChange?.(e.target.valueAsNumber);
} else
{
data.onChange?.(e.target.value);
}
}}
onBlur={handleInputBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge(
"flex text-base-content px-4 py-2 items-center justify-center border bg-base-200 border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-100 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content",
data.type === 'range' ? "range" : "",
data.className
)}
/>}
@ -83,10 +167,10 @@ export function OptionInput (data: {
type={data.type}
onClick={() => { oneShot("click"); }}
autoComplete={data.autocomplete}
onFocus={handleFocus}
onFocus={handleInputFocus}
placeholder={data.placeholder}
onChange={e => data.onChange?.(e.target.checked)}
onBlur={data.onBlur}
onBlur={handleInputBlur}
className={twMerge(
"active:bg-base-content rounded-full",
data.className

View file

@ -1,7 +1,7 @@
import { OptionContext } from "@/mainview/scripts/contexts";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Direction, FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX, useContext, useEffect, useMemo } from "react";
import { JSX, useContext, useEffect, useMemo, useState } from "react";
import { twMerge } from "tailwind-merge";
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
@ -40,12 +40,16 @@ export function OptionSpace (data: {
saveLastFocusedChild?: boolean;
})
{
const [focusBoundary, setFocusBoundary] = useState(false);
const [focusBoundaryDirections, setFocusBoundaryDirections] = useState<Direction[]>([]);
const eventTarget = useMemo(() => new EventTarget(), []);
const { ref, focused, focusSelf, focusKey } = useFocusable({
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
isFocusBoundary: focusBoundary,
focusBoundaryDirections,
onFocus ()
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@ -71,7 +75,7 @@ export function OptionSpace (data: {
}
return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<OptionContext value={{ focused, focus: focusSelf, setFocusBoundary, setFocusBoundaryDirections, 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! rounded-3xl border-b border-base-content/5 focused:bg-base-300 focused-child:bg-base-300",