mirror of
https://github.com/SteamDeckHomebrew/decky-frontend-lib.git
synced 2026-05-25 04:18:48 +02:00
feat: added reorderable list and updated fieldProps
This commit is contained in:
@@ -78,5 +78,8 @@
|
||||
"style": "module",
|
||||
"parser": "typescript"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"react-icons": "^4.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -16,6 +16,7 @@ specifiers:
|
||||
minimist: ^1.2.6
|
||||
prettier: ^2.7.1
|
||||
prettier-plugin-import-sort: ^0.0.7
|
||||
react-icons: ^4.6.0
|
||||
semantic-release: ^19.0.3
|
||||
shx: ^0.3.4
|
||||
ts-jest: ^27.1.4
|
||||
@@ -25,6 +26,9 @@ specifiers:
|
||||
typedoc-plugin-missing-exports: ^1.0.0
|
||||
typescript: ^4.6.3
|
||||
|
||||
dependencies:
|
||||
react-icons: 4.6.0
|
||||
|
||||
devDependencies:
|
||||
'@commitlint/cli': 17.0.2
|
||||
'@commitlint/config-conventional': 17.0.2
|
||||
@@ -1793,8 +1797,8 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
is-text-path: 1.0.1
|
||||
JSONStream: 1.3.5
|
||||
is-text-path: 1.0.1
|
||||
lodash: 4.17.21
|
||||
meow: 8.1.2
|
||||
split2: 3.2.2
|
||||
@@ -4210,6 +4214,15 @@ packages:
|
||||
strip-json-comments: 2.0.1
|
||||
dev: true
|
||||
|
||||
/react-icons/4.6.0:
|
||||
resolution: {integrity: sha512-rR/L9m9340yO8yv1QT1QurxWQvWpbNHqVX0fzMln2HEb9TEIrQRGsqiNFQfiv9/JEUbyHmHPlNTB2LWm2Ttz0g==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
dev: false
|
||||
|
||||
/react-is/17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
dev: true
|
||||
|
||||
301
src/custom-components/ReorderableList.tsx
Normal file
301
src/custom-components/ReorderableList.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { FaEllipsisH, FaArrowsAltV } from "react-icons/fa";
|
||||
import { ButtonItem, DialogButton, Field, Focusable } from "../deck-components";
|
||||
import { GamepadButton, GamepadEvent } from "../deck-components/FooterLegend"
|
||||
|
||||
interface Positioned {
|
||||
position: number
|
||||
}
|
||||
|
||||
export type ReorderableEntry<T extends Positioned> = {
|
||||
key: string,
|
||||
label: string,
|
||||
data: T
|
||||
}
|
||||
|
||||
export type ReorderableEntryProps<T extends Positioned> = {
|
||||
entry: ReorderableEntry<T>,
|
||||
index: number,
|
||||
action: (e:MouseEvent, entry:ReorderableEntry<T>) => void
|
||||
}
|
||||
|
||||
export type ReorderableListData<T extends Positioned> = {
|
||||
[key:string]: ReorderableEntry<T>
|
||||
}
|
||||
|
||||
export type ReloadData = {
|
||||
showReload: boolean,
|
||||
reload: () => Promise<void>,
|
||||
reloadLabel?: string
|
||||
}
|
||||
|
||||
type ReorderableListProps<T extends Positioned> = {
|
||||
data: ReorderableListData<T>,
|
||||
action: (e:MouseEvent, entry:ReorderableEntry<T>) => void,
|
||||
onUpdate: (data: {[key:string]:T}) => void,
|
||||
reloadData: ReloadData
|
||||
}
|
||||
|
||||
const ELEM_HEIGHT = 32; //height of each ReorderableEntry element
|
||||
|
||||
export function ReorderableList<T extends Positioned>(props: ReorderableListProps<T>) {
|
||||
let reorderEnabled = useRef(false);
|
||||
const touchOrigin = useRef({"x": -1, "y": -1});
|
||||
const mouseOrigin = useRef({"x": -1, "y": -1});
|
||||
let focusedSide = useRef(false); //false = left, true = right
|
||||
let focusIdx = useRef(0);
|
||||
|
||||
let data = props.data;
|
||||
let onUpdate = props.onUpdate;
|
||||
let dataAsList:ReorderableEntry<T>[] = Object.values(props.data).sort((a, b) => a.data.position - b.data.position);;
|
||||
|
||||
const [update, setUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
dataAsList = [];
|
||||
dataAsList = Object.values(props.data).sort((a, b) => a.data.position - b.data.position);
|
||||
data = props.data;
|
||||
});
|
||||
|
||||
function enableReorder() { reorderEnabled.current = true; }
|
||||
function disabledReorder() { reorderEnabled.current = false; }
|
||||
function forceUpdate() { setUpdate(update === 0 ? 1 : 0); }
|
||||
|
||||
function ReorderableEntry(props: ReorderableEntryProps<T>) {
|
||||
const wrapperFocusable = useRef<HTMLDivElement>(null);
|
||||
const reorderBtn = useRef<HTMLDivElement>(null);
|
||||
const optionsBtn = useRef<HTMLDivElement>(null);
|
||||
|
||||
let lastEvent = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIdx.current === props.index) {
|
||||
if (!focusedSide.current) {
|
||||
optionsBtn.current?.blur();
|
||||
reorderBtn.current?.focus();
|
||||
} else {
|
||||
reorderBtn.current?.blur();
|
||||
optionsBtn.current?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function reorder(down:boolean) {
|
||||
if ((down && props.entry.data.position != dataAsList.length) || (!down && props.entry.data.position != 1)) {
|
||||
const thisData = props.entry;
|
||||
const previous = dataAsList[down ? props.index+1 : props.index-1];
|
||||
const tmp = thisData.data.position;
|
||||
thisData.data.position = previous.data.position;
|
||||
previous.data.position = tmp;
|
||||
|
||||
const refs = data;
|
||||
refs[thisData.key] = thisData;
|
||||
refs[previous.key] = previous;
|
||||
|
||||
const toSave:{[key:string]:T} = {};
|
||||
Object.values(refs).map((val:ReorderableEntry<T>) => {
|
||||
toSave[val.key] = val.data;
|
||||
})
|
||||
onUpdate(toSave);
|
||||
|
||||
if (down) {
|
||||
focusIdx.current++;
|
||||
} else {
|
||||
focusIdx.current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="custom-buttons">
|
||||
<Field label={props.entry.label} onActivate={() => { focusIdx.current = props.index; }} ref={wrapperFocusable} style={{ width: "100%" }}>
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%"
|
||||
}}
|
||||
onGamepadDirection={(e:GamepadEvent) => {
|
||||
switch(e.detail.button) {
|
||||
case GamepadButton.DIR_DOWN: {
|
||||
|
||||
if (reorderEnabled.current && props.entry.data.position === dataAsList.length) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
if (reorderEnabled.current && props.entry.data.position != dataAsList.length) reorder(true);
|
||||
|
||||
if (props.entry.data.position != dataAsList.length) {
|
||||
focusIdx.current++;
|
||||
forceUpdate();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GamepadButton.DIR_UP: {
|
||||
if (reorderEnabled.current && props.entry.data.position === 1) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
if (reorderEnabled.current && props.entry.data.position != 1) reorder(false);
|
||||
|
||||
if (props.entry.data.position != 1) {
|
||||
focusIdx.current--;
|
||||
forceUpdate();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GamepadButton.DIR_LEFT: {
|
||||
lastEvent = true;
|
||||
if (focusedSide.current) {
|
||||
focusedSide.current = false;
|
||||
}
|
||||
reorderEnabled.current = false;
|
||||
}
|
||||
case GamepadButton.DIR_RIGHT: {
|
||||
if (!lastEvent) {
|
||||
if (!focusedSide.current) {
|
||||
focusedSide.current = true;
|
||||
}
|
||||
reorderEnabled.current = false;
|
||||
} else {
|
||||
lastEvent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onMouseMove={(e:React.MouseEvent<HTMLDivElement>) => {
|
||||
// once user has moved height of an entry, swap
|
||||
if (reorderEnabled.current) {
|
||||
const dy = e.clientY - mouseOrigin.current.y;
|
||||
if (Math.abs(dy) >= ELEM_HEIGHT) {
|
||||
reorder(dy > 0);
|
||||
mouseOrigin.current = {
|
||||
"x": e.clientX,
|
||||
"y": e.clientY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTouchMove={(e:React.TouchEvent<HTMLDivElement>) => {
|
||||
if (reorderEnabled.current) {
|
||||
const dy = e.touches[0].clientY - touchOrigin.current.y;
|
||||
if (Math.abs(dy) >= ELEM_HEIGHT) {
|
||||
reorder(dy > 0);
|
||||
touchOrigin.current = {
|
||||
"x": e.touches[0].clientX,
|
||||
"y": e.touches[0].clientY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
style={{
|
||||
marginRight: "14px",
|
||||
minWidth: "30px",
|
||||
maxWidth: "60px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
ref={reorderBtn}
|
||||
// @ts-ignore
|
||||
onOKActionDescription={"Hold to reorder items"}
|
||||
onButtonDown={(e:GamepadEvent) => {
|
||||
switch(e.detail.button) {
|
||||
case GamepadButton.OK: {
|
||||
enableReorder();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onButtonUp={(e:GamepadEvent) => {
|
||||
switch(e.detail.button) {
|
||||
case GamepadButton.OK: {
|
||||
disabledReorder();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e:MouseEvent) => {
|
||||
mouseOrigin.current = {
|
||||
"x": e.clientX,
|
||||
"y": e.clientY,
|
||||
}
|
||||
enableReorder();
|
||||
}}
|
||||
onTouchStart={(e:TouchEvent) => {
|
||||
touchOrigin.current = {
|
||||
"x": e.touches[0].clientX,
|
||||
"y": e.touches[0].clientY,
|
||||
}
|
||||
enableReorder();
|
||||
}}
|
||||
>
|
||||
<FaArrowsAltV />
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={{
|
||||
minWidth: "30px",
|
||||
maxWidth: "60px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
onClick={(e:MouseEvent) => {props.action(e, props.entry);}}
|
||||
ref={optionsBtn}
|
||||
>
|
||||
<FaEllipsisH />
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</Field>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<style>{`
|
||||
.scoper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`}</style>
|
||||
<div className="scoper"
|
||||
onMouseUp={() => {
|
||||
mouseOrigin.current = {
|
||||
"x": -1,
|
||||
"y": -1,
|
||||
}
|
||||
disabledReorder();
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
touchOrigin.current = {
|
||||
"x": -1,
|
||||
"y": -1,
|
||||
}
|
||||
disabledReorder();
|
||||
}}
|
||||
>
|
||||
{dataAsList.length > 0 ?
|
||||
dataAsList.map((itm: ReorderableEntry<T>, i:number) => (
|
||||
<ReorderableEntry entry={itm} index={i} action={props.action} />
|
||||
)) : (
|
||||
<div style={{width: "100%", display: "flex", justifyContent: "center", alignItems: "center", padding: "5px"}}>
|
||||
No data to display right now.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{props.reloadData.showReload ? (
|
||||
<ButtonItem layout="below" onClick={props.reloadData.reload} bottomSeparator='none'>
|
||||
Reload {props.reloadData.reloadLabel}
|
||||
</ButtonItem>
|
||||
) : ""}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FC, ReactNode, RefAttributes } from 'react';
|
||||
import { CSSProperties, FC, ReactNode, RefAttributes } from 'react';
|
||||
|
||||
import { findModuleChild } from '../webpack';
|
||||
import { FooterLegendProps } from './FooterLegend';
|
||||
|
||||
export interface FieldProps extends FooterLegendProps {
|
||||
style?: CSSProperties,
|
||||
label?: ReactNode;
|
||||
bottomSeparator?: 'standard' | 'thick' | 'none';
|
||||
description?: ReactNode;
|
||||
|
||||
Reference in New Issue
Block a user