From 3c171cfb8ff18ed02eeb569a183c9d43fd0b4f57 Mon Sep 17 00:00:00 2001 From: Tormak <63308171+Tormak9970@users.noreply.github.com> Date: Fri, 25 Nov 2022 18:32:55 -0500 Subject: [PATCH] feat: added reorderable list and updated fieldProps --- package.json | 3 + pnpm-lock.yaml | 15 +- src/custom-components/ReorderableList.tsx | 301 ++++++++++++++++++++++ src/deck-components/Field.tsx | 3 +- 4 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 src/custom-components/ReorderableList.tsx diff --git a/package.json b/package.json index 2f0a6bb..c3837da 100644 --- a/package.json +++ b/package.json @@ -78,5 +78,8 @@ "style": "module", "parser": "typescript" } + }, + "dependencies": { + "react-icons": "^4.6.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7401639..85d30e3 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/custom-components/ReorderableList.tsx b/src/custom-components/ReorderableList.tsx new file mode 100644 index 0000000..625d38a --- /dev/null +++ b/src/custom-components/ReorderableList.tsx @@ -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 = { + key: string, + label: string, + data: T +} + +export type ReorderableEntryProps = { + entry: ReorderableEntry, + index: number, + action: (e:MouseEvent, entry:ReorderableEntry) => void +} + +export type ReorderableListData = { + [key:string]: ReorderableEntry +} + +export type ReloadData = { + showReload: boolean, + reload: () => Promise, + reloadLabel?: string +} + +type ReorderableListProps = { + data: ReorderableListData, + action: (e:MouseEvent, entry:ReorderableEntry) => void, + onUpdate: (data: {[key:string]:T}) => void, + reloadData: ReloadData +} + +const ELEM_HEIGHT = 32; //height of each ReorderableEntry element + +export function ReorderableList(props: ReorderableListProps) { + 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[] = 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) { + const wrapperFocusable = useRef(null); + const reorderBtn = useRef(null); + const optionsBtn = useRef(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) => { + toSave[val.key] = val.data; + }) + onUpdate(toSave); + + if (down) { + focusIdx.current++; + } else { + focusIdx.current--; + } + } + } + + return ( + +
+ { focusIdx.current = props.index; }} ref={wrapperFocusable} style={{ width: "100%" }}> + { + 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) => { + // 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) => { + 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, + } + } + } + }} + > + { + 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(); + }} + > + + + {props.action(e, props.entry);}} + ref={optionsBtn} + > + + + + +
+
+ ); + } + + return ( + + +
{ + mouseOrigin.current = { + "x": -1, + "y": -1, + } + disabledReorder(); + }} + onTouchEnd={() => { + touchOrigin.current = { + "x": -1, + "y": -1, + } + disabledReorder(); + }} + > + {dataAsList.length > 0 ? + dataAsList.map((itm: ReorderableEntry, i:number) => ( + + )) : ( +
+ No data to display right now. +
+ ) + } + {props.reloadData.showReload ? ( + + Reload {props.reloadData.reloadLabel} + + ) : ""} +
+
+ ); +} \ No newline at end of file diff --git a/src/deck-components/Field.tsx b/src/deck-components/Field.tsx index efe4703..b2aedef 100644 --- a/src/deck-components/Field.tsx +++ b/src/deck-components/Field.tsx @@ -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;