void> = {};
+
+ const stopAllListeners = () => {
+ clearInterval(pollInterval);
+ // remove axes callbacks
+ for (const alias in registeredCallbacks) {
+ gamepad.off([alias as GamepadInput], registeredCallbacks[alias]);
+ }
+ };
+
+ const bindAndCleanup = (inputCode: KeyCode, isButton: boolean = false, pressedBtnObj?: GamepadButton) => {
+ if (isFinished) return;
+ isFinished = true;
+
+ stopAllListeners();
+
+ const prevKey = context.bindings[props.action] || null;
+ const success = context.bind(props.action, inputCode);
+
+ if (success) context.onChange?.(prevKey, String(inputCode), props.action);
+ setListening(false);
+
+ if (isButton && pressedBtnObj) {
+ const releaseInterval = setInterval(() => {
+ if (!pressedBtnObj.pressed) {
+ clearInterval(releaseInterval);
+ nav?.resumeInput();
+ }
+ }, 500);
+ } else {
+ nav?.resumeInput();
+ }
+ };
+
+ // Stick logic
+ GamepadMappings.axisAliases.forEach((alias, index) => {
+ if (index <= 1) return;
+ // Create the specific reference
+ const callback = () => bindAndCleanup(alias as KeyCode);
+ // Save it for later
+ registeredCallbacks[alias] = callback;
+ // Register it
+ gamepad.on({ actions: [alias], callback });
+ });
+
+ // Button logic
+ const pollInterval = setInterval(() => {
+ const gamepad = navigator.getGamepads()[0];
+ if (!gamepad || isFinished) return;
+
+ let buttonIdx = -1;
+ const pressedButton = gamepad?.buttons.find((btn, idx) => {
+ if (btn.pressed) {
+ buttonIdx = idx;
+ return btn
+ }
+ })
+
+ if (pressedButton) bindAndCleanup(buttonIdx as any as KeyCode, true, pressedButton);
+ }, 150)
+ }
+
const eatEvent = (e :Event) => {
e.preventDefault();
e.stopPropagation();
@@ -80,7 +167,7 @@ const Keybind = (props: KeyBindProps) => {
})
props.componentClasses = style.keybind;
- const { className, inlineStyles, forwardEvents, forwardAttrs } = useBaseComponent(props);
+ const { className, inlineStyles, forwardEvents, forwardAttrs, navigationActions } = useBaseComponent(props);
return (
{
style={inlineStyles()}
use:forwardEvents={props}
use:forwardAttrs={props}
+ use:navigationActions={mergeNavigationActions(props, {'select': startListeningGamepad})}
onmouseup={startListening}>
{label()}
diff --git a/src/components/Basic/Keybinds/Keybinds.module.scss b/src/components/Basic/Keybinds/Keybinds.module.scss
index 57b753d..7ab3a0e 100644
--- a/src/components/Basic/Keybinds/Keybinds.module.scss
+++ b/src/components/Basic/Keybinds/Keybinds.module.scss
@@ -8,7 +8,8 @@
align-items: center;
justify-content: center;
- &:hover {
+ &:hover,
+ &:focus {
border-color: rgba($primaryColor, 0.75);
}
}
\ No newline at end of file
diff --git a/src/components/Basic/Keybinds/Keybinds.tsx b/src/components/Basic/Keybinds/Keybinds.tsx
index 13c68a5..b156498 100644
--- a/src/components/Basic/Keybinds/Keybinds.tsx
+++ b/src/components/Basic/Keybinds/Keybinds.tsx
@@ -2,11 +2,14 @@ import { BaseComponentRef } from "@components/types/ComponentProps";
import { Accessor, batch, createContext, createEffect, createMemo, on, onMount, ParentComponent } from "solid-js";
import { createStore, unwrap } from "solid-js/store";
import {BindingCode, BindingLabel, MAPPINGS} from "./util/mappings";
+import { GlyphOverrides } from "./util/glyphs";
+import { GLYPHS as DEFAULT_GLYPHS } from "./util/glyphs";
+import { AxisInput, GamepadInput, GamepadMappings } from "coherent-gameface-interaction-manager";
export type Action = string;
-export type KeyCode = BindingLabel | (string & {}) | null;
+export type KeyCode = BindingLabel | GamepadInput | (string & {}) | null;
export type ConflictPolicy = 'block' | 'replace-existing' | 'swap' | 'allow-duplicates'
-type Bindings = Record;
+export type Bindings = Record;
export interface KeybindsRef {
bindings: Bindings,
@@ -17,32 +20,38 @@ export interface KeybindsRef {
reset: () => void,
}
-interface KeybindsContext {
+interface SharedProps {
+ onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void,
+ mode?: 'gamepad' | 'keyboard'
+}
+
+interface KeybindsContext extends SharedProps {
KEYS: Record,
+ GLYPHS: GlyphOverrides,
bindings: Bindings,
bind: (action: Action, newKey: KeyCode) => boolean,
placeholder?: Accessor,
listeningText?: Accessor,
- useChars?: Accessor
- onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void,
+ useChars?: Accessor,
}
export const KeybindsContext = createContext();
-interface KeybindsProps {
+interface KeybindsProps extends SharedProps {
defaults?: Bindings,
overrides?: Partial>,
placeholder?: string,
listeningText?: string,
conflictPolicy?: ConflictPolicy,
+ glyphOverrides?: GlyphOverrides;
ref?: unknown | ((ref: BaseComponentRef) => void);
onConflict?: (action: Action, key: KeyCode, conflictAction: Action) => void,
- onChange?: (prev: KeyCode, next: KeyCode, action: Action) => void,
}
const Keybinds: ParentComponent = (props) => {
const KEYS = {...MAPPINGS, ...props.overrides}; // { event.code: label }
const DISPLAY_LABELS = new Set(Object.values(KEYS)); // Set of valid labels only
+ const GLYPHS = {... DEFAULT_GLYPHS, ...props.glyphOverrides };
const [bindings, setBindings] = createStore({});
let defaults = props.defaults ?? undefined;
@@ -55,6 +64,26 @@ const Keybinds: ParentComponent = (props) => {
mapBindings({...bindings})
}, {defer: true}));
+ const sanitizeButtonInput = (input: string | number) => {
+ const num = Number(input);
+ if (!isNaN(num) || typeof input === 'number') {
+ if (0 <= num && num <= 16) return String(input);
+ else {
+ console.warn(`${input} is not a valid gamepad button. Please ensure the number is between 0 and 16 inclusive`);
+ return null
+ }
+ }
+
+ const button = input.toLowerCase();
+ if ((GamepadMappings.axisAliases as readonly string[]).includes(button)) return button as AxisInput;
+
+ const key = GamepadMappings.aliases[button as keyof typeof GamepadMappings.aliases];
+ if (key) return String(GamepadMappings[key]);
+
+ console.warn(`${input} is not a valid gamepad button.`);
+ return null;
+ }
+
const verifyKey = (key: string | null) => {
if (key === null) return null;
if (DISPLAY_LABELS.has(key)) return key;
@@ -78,14 +107,17 @@ const Keybinds: ParentComponent = (props) => {
const prevKey = bindings[action];
if (prevKey === newKey) return false;
+ newKey = props.mode === 'gamepad'
+ ? sanitizeButtonInput(newKey as GamepadInput)
+ : verifyKey(newKey as BindingLabel);
+
const conflictAction = byKey.get(newKey);
// no conflict -> just bind
if (!conflictAction || conflictAction === action) {
if (prevKey) byKey.delete(prevKey);
- const verifiedKey = verifyKey(newKey);
- setBindings(action, verifiedKey);
- if (verifiedKey !== null) byKey.set(newKey, action);
+ setBindings(action, newKey);
+ if (newKey !== null) byKey.set(newKey, action);
return true
}
@@ -175,7 +207,9 @@ const Keybinds: ParentComponent = (props) => {
listeningText,
placeholder,
KEYS,
- onChange: props.onChange
+ GLYPHS,
+ onChange: props.onChange,
+ mode: props.mode
}
return (
diff --git a/src/components/Basic/Keybinds/util/glyphs.ts b/src/components/Basic/Keybinds/util/glyphs.ts
new file mode 100644
index 0000000..6071b51
--- /dev/null
+++ b/src/components/Basic/Keybinds/util/glyphs.ts
@@ -0,0 +1,53 @@
+import { Icon } from "@components/Media/Icon/Icon";
+import { Component, JSX } from "solid-js";
+
+/**
+ * Maps standard Gamepad API button indices to Gameface-ui Icon components.
+ * [Reference](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API#button_layout)
+ */
+export const GLYPHS = {
+ // Face Buttons
+ '0': Icon.gamepad.xbox.a,
+ '1': Icon.gamepad.xbox.b,
+ '2': Icon.gamepad.xbox.x,
+ '3': Icon.gamepad.xbox.y,
+
+ // Shoulder Buttons (Bumpers)
+ '4': Icon.gamepad.xbox.lb,
+ '5': Icon.gamepad.xbox.rb,
+
+ // Triggers
+ '6': Icon.gamepad.xbox.lt,
+ '7': Icon.gamepad.xbox.rt,
+
+ // Navigation / Center Buttons
+ '8': Icon.gamepad.xbox.view, // Often used as 'Back' or 'Select'
+ '9': Icon.gamepad.xbox.menu, // Often used as 'Start'
+
+ // Stick Presses (L3 / R3)
+ '10': Icon.gamepad.xbox.leftStickPress,
+ '11': Icon.gamepad.xbox.rightStickPress,
+
+ // D-Pad
+ '12': Icon.gamepad.xbox.dpadUp,
+ '13': Icon.gamepad.xbox.dpadDown,
+ '14': Icon.gamepad.xbox.dpadLeft,
+ '15': Icon.gamepad.xbox.dpadRight,
+
+ // Additional Button (Xbox Guide/Share)
+ '16': Icon.gamepad.xbox.share,
+
+ 'right.joystick': Icon.gamepad.xbox.rightStick,
+ 'left.joystick': Icon.gamepad.xbox.leftStick,
+ 'left.joystick.down': Icon.gamepad.xbox.leftStick,
+ 'left.joystick.up': Icon.gamepad.xbox.leftStick,
+ 'left.joystick.left': Icon.gamepad.xbox.leftStick,
+ 'left.joystick.right': Icon.gamepad.xbox.leftStick,
+ 'right.joystick.down': Icon.gamepad.xbox.rightStick,
+ 'right.joystick.up': Icon.gamepad.xbox.rightStick,
+ 'right.joystick.left': Icon.gamepad.xbox.rightStick,
+ 'right.joystick.right': Icon.gamepad.xbox.rightStick
+};
+
+export type GamepadBindingCode = keyof typeof GLYPHS;
+export type GlyphOverrides = Partial | JSX.Element>>;
\ No newline at end of file
diff --git a/src/components/Media/Icon/Icon.tsx b/src/components/Media/Icon/Icon.tsx
index 3e14b78..f707f60 100644
--- a/src/components/Media/Icon/Icon.tsx
+++ b/src/components/Media/Icon/Icon.tsx
@@ -30,7 +30,7 @@ const IconComponent = (src: string): Component => {
{...props}
use:forwardAttrs={props}
src={src}
- class={`${styles.icon} ${props.class || ''}`}
+ class={`${styles.icon} ${props?.class || ''}`}
onError={() => setHasError(true)}
/>
)}
diff --git a/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts
index ab91f2e..88c5bff 100644
--- a/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts
+++ b/src/components/Utility/Navigation/actionMethods/actionMethods.types.ts
@@ -83,6 +83,16 @@ export interface ActionMethods {
*/
resumeAction: (action: ActionName, force?: boolean) => void;
+ /**
+ * Snapshots current pause states and forcefully pauses all actions (ideal for stopping all action input). `resumeInput` must be used to unpause all actions.
+ */
+ pauseInput: () => void;
+
+ /**
+ * Releases the global pause and restores actions to their state prior to the pauseInput call.
+ */
+ resumeInput: () => void;
+
/**
* Checks if an action is currently paused
* @param action - The name of the action to check
diff --git a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts
index 3a2ce2a..0de38d2 100644
--- a/src/components/Utility/Navigation/actionMethods/useActionMethods.ts
+++ b/src/components/Utility/Navigation/actionMethods/useActionMethods.ts
@@ -13,6 +13,8 @@ export default function createActionMethods(
const actionSubscribers = new Map();
// For force paused actions
const forcePausedActions = new Set();
+ // For remembering which already paused actions were affected in the pause input function
+ const inputCaptureSnapshot = new Set();
const registerAction = (actionName: ActionName) => {
const {key, button, callback, global} = getAction(actionName)!;
@@ -156,6 +158,35 @@ export default function createActionMethods(
return getAction(name)?.paused ?? false;
};
+ const pauseInput = () => {
+ const allActions = getActions();
+ inputCaptureSnapshot.clear();
+
+ for (const actionName in allActions) {
+ if (isPaused(actionName)) {
+ inputCaptureSnapshot.add(actionName);
+ }
+
+ pauseAction(actionName, true);
+ }
+ };
+
+ const resumeInput = () => {
+ const allActions = getActions();
+
+ for (const actionName in allActions) {
+ if (inputCaptureSnapshot.has(actionName)) {
+ // remove the force flag only
+ forcePausedActions.delete(actionName);
+ continue;
+ }
+
+ resumeAction(actionName, true);
+ }
+
+ inputCaptureSnapshot.clear();
+ };
+
return {
addAction,
removeAction,
@@ -168,6 +199,8 @@ export default function createActionMethods(
getActions,
pauseAction,
resumeAction,
+ pauseInput,
+ resumeInput,
isPaused,
};
}
diff --git a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts
index 35d5a59..a69275b 100644
--- a/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts
+++ b/src/components/Utility/Navigation/areaMethods/areaMethods.types.ts
@@ -1,4 +1,4 @@
-import { KeyName } from "coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings";
+import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager';
/**
* Interface defining all area-related navigation methods
diff --git a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts
index 2b51adc..bdd5536 100644
--- a/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts
+++ b/src/components/Utility/Navigation/areaMethods/useAreaMethods.ts
@@ -3,7 +3,7 @@ import { SetStoreFunction } from 'solid-js/store';
import { NavigationConfigType } from '../types';
import { AreaMethods } from './areaMethods.types';
import { spatialNavigation } from 'coherent-gameface-interaction-manager';
-import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings';
+import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager';
export default function createAreaMethods(
areas: Set,
diff --git a/src/components/Utility/Navigation/types.ts b/src/components/Utility/Navigation/types.ts
index de54d06..4dd98b0 100644
--- a/src/components/Utility/Navigation/types.ts
+++ b/src/components/Utility/Navigation/types.ts
@@ -1,5 +1,4 @@
-import { KeyName } from 'coherent-gameface-interaction-manager/dist/types/utils/keyboard-mappings';
-import { GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/utils/gamepad-mappings';
+import { KeyName, GamepadInput } from 'coherent-gameface-interaction-manager/dist/types/interaction-manager';
type ActionType = 'press' | 'hold' | 'lift';
diff --git a/tests/shared/keybinds/glyph-mappings.json b/tests/shared/keybinds/glyph-mappings.json
new file mode 100644
index 0000000..db47644
--- /dev/null
+++ b/tests/shared/keybinds/glyph-mappings.json
@@ -0,0 +1,12 @@
+[
+ { "action": "moveForward", "key": "12", "glyph": "dpadUp" },
+ { "action": "moveBackward", "key": "13", "glyph": "dpadDown" },
+ { "action": "moveLeft", "key": "14", "glyph": "dpadLeft" },
+ { "action": "moveRight", "key": "15", "glyph": "dpadRight" },
+ { "action": "jump", "key": "0", "glyph": "a" },
+ { "action": "crouch", "key": "2", "glyph": "x" },
+ { "action": "sprint", "key": "10", "glyph": "leftStickPress" },
+ { "action": "fire", "key": "7", "glyph": "rt" },
+ { "action": "aimDownSights","key": "6", "glyph": "lt" },
+ { "action": "reload", "key": "1", "glyph": "b" }
+]
\ No newline at end of file
diff --git a/tests/shared/keybinds/keybinds-selectors.json b/tests/shared/keybinds/keybinds-selectors.json
index 02c7689..2e44f79 100644
--- a/tests/shared/keybinds/keybinds-selectors.json
+++ b/tests/shared/keybinds/keybinds-selectors.json
@@ -5,5 +5,6 @@
"keybind": "keybind",
"listening-text": "Please type",
"placeholder-text": "Unassigned",
- "reactive": "reactive"
+ "reactive": "reactive",
+ "actionTest": "action-test-element"
}
\ No newline at end of file
diff --git a/tests/specs/keybinds.spec.js b/tests/specs/keybinds.spec.js
index 0743a4e..b9fedb1 100644
--- a/tests/specs/keybinds.spec.js
+++ b/tests/specs/keybinds.spec.js
@@ -2,6 +2,7 @@ const assert = require('assert');
const selectors = require('../shared/keybinds/keybinds-selectors.json');
const DEFAULT_MAPPINGS = require('../shared/keybinds/default-mappings.json');
const ALTERNATE_MAPPINGS = require('../shared/keybinds/alternate-mappings.json');
+const GAMEPAD_GLYPHS = require ("../shared/keybinds/glyph-mappings.json");
const OVERRIDES = require('../shared/keybinds/overrides.json');
const { navigateToPage } = require('../shared/utils');
@@ -256,4 +257,75 @@ describe('Keybinds', function () {
assert.strictEqual(await keybinds[1].text(), keybind2InitialText, `Keybind should have its initial value`);
});
})
+
+ describe('Keybinds - gamepad mode', function () {
+ this.beforeAll(async () => {
+ await gf.connectGamepad('gamepad')
+ await gf.click(`.${selectors.scenarioBtn}.scenario-12`)
+ })
+
+ async function getIconSource (keybind) {
+ const image = await keybind.find('img');
+ return await image.getAttribute('src');
+ }
+
+ it('Should render default gamepad glyphs', async () => {
+ const container = await gf.getAll(`.${selectors.keybindsContainer}`);
+ const keybinds = await container[2].findAll(`.${selectors.keybind}`);
+
+ let idx = 0;
+ for (const keybind of keybinds) {
+ const src = await getIconSource(keybind);
+
+ assert.equal(src.includes(`/${GAMEPAD_GLYPHS[idx].glyph}`), true, 'Correct icon is displayed');
+ idx++;
+ }
+ });
+
+ it('Should rebind a gamepad input', async () => {
+ const container = await gf.getAll(`.${selectors.keybindsContainer}`);
+ const keybind = await container[2].find(`.${selectors.keybind}`);
+ const gamepad = gf.getGamepad('gamepad')
+
+ await gamepad.sequence([
+ gf.GAMEPAD_BUTTONS.FACE_BUTTON_DOWN,
+ gf.GAMEPAD_BUTTONS.FACE_BUTTON_RIGHT,
+ ]);
+
+ const src = await getIconSource(keybind);
+ assert.equal(src.includes(`/b`), true, 'Correct icon is displayed');
+ })
+
+ it('Should prevent execution of any actions while listening for input', async () => {
+ const assertionEl = await gf.get(`.${selectors.actionTest}`)
+ const gamepad = gf.getGamepad('gamepad')
+
+ await gamepad.sequence([
+ gf.GAMEPAD_BUTTONS.FACE_BUTTON_DOWN,
+ gf.GAMEPAD_BUTTONS.RIGHT_SHOULDER,
+ ]);
+
+ assert.equal(await assertionEl.text(), 'test', 'Element\'s text should remain unchanged after action is bound')
+ })
+
+ it('Should render custom glyphs', async () => {
+ const container = await gf.getAll(`.${selectors.keybindsContainer}`);
+ const keybinds = await container[3].findAll(`.${selectors.keybind}`);
+
+ const NEW_GLYPHS_TEXT = ['up', 'down', 'left', 'right'];
+ let idx = 0;
+
+ for (const keybind of keybinds) {
+ if (idx === NEW_GLYPHS_TEXT.length) break;
+
+ const child = await keybind.find('div');
+ assert.equal(await child.text(), NEW_GLYPHS_TEXT[idx], 'Glyph text content should match structure.');
+ if (idx === NEW_GLYPHS_TEXT.length - 1) {
+ const styles = await child.styles();
+ assert.equal(styles['background-color'], 'rgba(0, 0, 255, 1)', 'Styles of glyphs changed');
+ }
+ idx++;
+ }
+ })
+ })
});
\ No newline at end of file
diff --git a/tests/src/components/Keybinds/KeybindsTest.tsx b/tests/src/components/Keybinds/KeybindsTest.tsx
index 173d5d5..03b5f4f 100644
--- a/tests/src/components/Keybinds/KeybindsTest.tsx
+++ b/tests/src/components/Keybinds/KeybindsTest.tsx
@@ -1,22 +1,26 @@
import Tab from "@components/Layout/Tab/Tab";
-import { createMemo, createSignal, For, Match, onCleanup, onMount, Switch } from "solid-js";
+import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
import selectors from "../../../shared/keybinds/keybinds-selectors.json";
import Keybinds, { KeybindsRef } from "@components/Basic/Keybinds/Keybinds";
import Keybind from "@components/Basic/Keybinds/Keybind";
import TEST_KEYS from '../../../shared/keybinds/default-mappings.json';
import ALTERNATE_KEYS from "../../../shared/keybinds/alternate-mappings.json";
+import GAMEPAD_GLYPHS from "../../../shared/keybinds/glyph-mappings.json";
import OVERRIDES from "../../../shared/keybinds/overrides.json";
import Flex from "@components/Layout/Flex/Flex";
+import Navigation, { NavigationRef } from "@components/Utility/Navigation/Navigation";
const KeybindsTest = () => {
let keybindsRef!: KeybindsRef;
let keybindsRefWithDefault!: KeybindsRef;
+ let nav!: NavigationRef;
const [test, setTest] = createSignal('red');
const [lastChanged, setLastChanged] = createSignal({ prev: "", next: "", action: ""});
const [hasConflict, setHasConflict] = createSignal(false);
const [policy, setPolicy] = createSignal<'block' | 'replace-existing' | 'allow-duplicates' | 'swap'>('block')
const [listeningText, setListeningText] = createSignal(undefined)
const [placeholder, setPlaceholder] = createSignal('Unbound')
+ const [actionTest, setActionTest] = createSignal('test');
const scenarios = [
{ label: "Change policy - replace-existing", action: () => { setPolicy('replace-existing') } },
@@ -31,6 +35,7 @@ const KeybindsTest = () => {
{ label: "Change placeholder text", action: () => { setPlaceholder(selectors["placeholder-text"]) } },
{ label: "Change styles", action: () => { setTest('blue') } },
{ label: "Reset defaults - alternate", action: () => { keybindsRefWithDefault.reset() } },
+ { label: "Focus keybind nav area", action: () => { nav.focusFirst('keybinds') } },
];
const reset = () => {
@@ -43,6 +48,8 @@ const KeybindsTest = () => {
keybindsRef?.reset();
};
+ const testComponent = () => down
;
+
const isReactive = createMemo(() => test() === 'blue');
const reactiveClass = createMemo(() => isReactive() ? 'reactive' : '');
const reactiveStyle = createMemo(() => isReactive() ? { 'background-color': 'blue' } : {});
@@ -94,6 +101,37 @@ const KeybindsTest = () => {
+
+ {/* Gamepad support */}
+ setActionTest('changed')} }}>
+
+
+
+
+ {(entry) => }
+
+
+
+ {actionTest()}
+
+
+ up,
+ '13': testComponent,
+ '14': () => left
,
+ '15': () => right
+ }}>
+
+ {(entry) => }
+
+
+
+
)
}