mirror of
https://github.com/SteamDeckHomebrew/decky-frontend-lib.git
synced 2026-05-21 10:29:00 +02:00
Co-authored-by: AAGaming <aagaming@riseup.net> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
import { Fragment, JSXElementConstructor, ReactElement, ReactNode, useEffect, useState } from 'react';
|
|
|
|
import { Field, FieldProps, Focusable, GamepadButton } from '../components';
|
|
|
|
/**
|
|
* A ReorderableList entry of type <T>.
|
|
* @param label The name of this entry in the list.
|
|
* @param data Optional data to connect to this entry.
|
|
* @param position The position of this entry in the list.
|
|
*/
|
|
export type ReorderableEntry<T> = {
|
|
label: ReactNode;
|
|
data?: T;
|
|
position: number;
|
|
};
|
|
|
|
/**
|
|
* Properties for a ReorderableList component of type <T>.
|
|
*
|
|
* @param animate If the list should animate. @default true
|
|
*/
|
|
export type ReorderableListProps<T> = {
|
|
entries: ReorderableEntry<T>[];
|
|
onSave: (entries: ReorderableEntry<T>[]) => void;
|
|
interactables?: JSXElementConstructor<{ entry: ReorderableEntry<T> }>;
|
|
fieldProps?: FieldProps;
|
|
animate?: boolean;
|
|
};
|
|
|
|
/**
|
|
* A component for creating reorderable lists.
|
|
*
|
|
* See an example implementation {@linkplain https://github.com/Tormak9970/Component-Testing-Plugin/blob/main/src/testing-window/ReorderableListTest.tsx here}.
|
|
*/
|
|
export function ReorderableList<T>(props: ReorderableListProps<T>) {
|
|
if (props.animate === undefined) props.animate = true;
|
|
const [entryList, setEntryList] = useState<ReorderableEntry<T>[]>(
|
|
[...props.entries].sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position),
|
|
);
|
|
const [reorderEnabled, setReorderEnabled] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
setEntryList([...props.entries].sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position));
|
|
}, [props.entries]);
|
|
|
|
function toggleReorderEnabled(): void {
|
|
let newReorderValue = !reorderEnabled;
|
|
setReorderEnabled(newReorderValue);
|
|
|
|
if (!newReorderValue) {
|
|
props.onSave(entryList);
|
|
}
|
|
}
|
|
|
|
function saveOnBackout(e: Event) {
|
|
const event = e as CustomEvent;
|
|
if (event.detail.button == GamepadButton.CANCEL && reorderEnabled) {
|
|
setReorderEnabled(!reorderEnabled);
|
|
props.onSave(entryList);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<div
|
|
style={{
|
|
width: 'inherit',
|
|
height: 'inherit',
|
|
flex: '1 1 1px',
|
|
scrollPadding: '48px 0px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'flex-start',
|
|
alignContent: 'stretch',
|
|
}}
|
|
>
|
|
<Focusable
|
|
onSecondaryButton={toggleReorderEnabled}
|
|
onSecondaryActionDescription={reorderEnabled ? 'Save Order' : 'Reorder'}
|
|
onClick={toggleReorderEnabled}
|
|
onButtonDown={saveOnBackout}
|
|
>
|
|
{entryList.map((entry: ReorderableEntry<T>) => (
|
|
<ReorderableItem
|
|
animate={props.animate!}
|
|
listData={entryList}
|
|
entryData={entry}
|
|
reorderEntryFunc={setEntryList}
|
|
reorderEnabled={reorderEnabled}
|
|
fieldProps={props.fieldProps}
|
|
>
|
|
{props.interactables ? <props.interactables entry={entry} /> : null}
|
|
</ReorderableItem>
|
|
))}
|
|
</Focusable>
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Properties for a ReorderableItem component of type <T>
|
|
*/
|
|
export type ReorderableListEntryProps<T> = {
|
|
fieldProps?: FieldProps;
|
|
listData: ReorderableEntry<T>[];
|
|
entryData: ReorderableEntry<T>;
|
|
reorderEntryFunc: CallableFunction;
|
|
reorderEnabled: boolean;
|
|
animate: boolean;
|
|
children: ReactElement | null;
|
|
};
|
|
|
|
function ReorderableItem<T>(props: ReorderableListEntryProps<T>) {
|
|
const [isSelected, _setIsSelected] = useState<boolean>(false);
|
|
const [isSelectedLastFrame, setIsSelectedLastFrame] = useState<boolean>(false);
|
|
const listEntries = props.listData;
|
|
|
|
function onReorder(e: Event): void {
|
|
if (!props.reorderEnabled) return;
|
|
|
|
const event = e as CustomEvent;
|
|
const currentIdx = listEntries.findIndex((entryData: ReorderableEntry<T>) => entryData === props.entryData);
|
|
const currentIdxValue = listEntries[currentIdx];
|
|
if (currentIdx < 0) return;
|
|
|
|
let targetPosition: number = -1;
|
|
if (event.detail.button == GamepadButton.DIR_DOWN) {
|
|
targetPosition = currentIdxValue.position + 1;
|
|
} else if (event.detail.button == GamepadButton.DIR_UP) {
|
|
targetPosition = currentIdxValue.position - 1;
|
|
}
|
|
|
|
if (targetPosition >= listEntries.length || targetPosition < 0) return;
|
|
|
|
let otherToUpdate = listEntries.find((entryData: ReorderableEntry<T>) => entryData.position === targetPosition);
|
|
if (!otherToUpdate) return;
|
|
|
|
let currentPosition = currentIdxValue.position;
|
|
|
|
currentIdxValue.position = otherToUpdate.position;
|
|
otherToUpdate.position = currentPosition;
|
|
|
|
props.reorderEntryFunc(
|
|
[...listEntries].sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position),
|
|
);
|
|
}
|
|
|
|
async function setIsSelected(val: boolean) {
|
|
_setIsSelected(val);
|
|
// Wait 3 frames, then set. I have no idea why, but if you dont wait long enough it doesn't work.
|
|
for (let i = 0; i < 3; i++) await new Promise((res) => requestAnimationFrame(res));
|
|
setIsSelectedLastFrame(val);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={
|
|
props.animate
|
|
? {
|
|
transition:
|
|
isSelected || isSelectedLastFrame
|
|
? ''
|
|
: 'transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1)', // easeOutQuart https://easings.net/#easeOutQuart
|
|
transform: !props.reorderEnabled || isSelected ? 'scale(1)' : 'scale(0.9)',
|
|
opacity: !props.reorderEnabled || isSelected ? 1 : 0.7,
|
|
}
|
|
: {}
|
|
}
|
|
>
|
|
<Field
|
|
label={props.entryData.label}
|
|
{...props.fieldProps}
|
|
focusable={!props.children}
|
|
onButtonDown={onReorder}
|
|
onGamepadBlur={() => setIsSelected(false)}
|
|
onGamepadFocus={() => setIsSelected(true)}
|
|
>
|
|
<Focusable style={{ display: 'flex', width: '100%', position: 'relative' }}>{props.children}</Focusable>
|
|
</Field>
|
|
</div>
|
|
);
|
|
}
|