Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,8 @@
"style": "module",
"parser": "typescript"
}
},
"dependencies": {
"react-icons": "^4.11.0"
}
}
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions src/custom-components/CustomButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { ReactNode, FC, useState } from "react";
import { FocusableProps, Focusable, DialogButton } from '../deck-components';
import { SoundFile, SFXPath, GamepadUIAudio } from '../utils/GamepadUIAudio';

export interface CustomButtonProps extends Omit<FocusableProps, 'focusWithinClassName' | 'flow-children' | 'onActivate' | 'onCancel' | 'onClick' | 'children' | 'noFocusRing' | 'onChange'> {
/** The sound effect to use when clicking @default 'deck_ui_default_activation.wav' */
audioSFX?: SoundFile;

/** Whether or not the button sound effect should be disable @default false */
noAudio?: boolean;

/** Whether or not the button should be transparent @default false */
transparent?: boolean;

/** The type of indicator to use when focused @default highlight */
focusMode?: CustomButtonFocusMode;

/** Callback function to be executed when the button is clicked */
onClick?: (e: CustomEvent) => void;

/** CSS class name for the button's container div */
containerClassName?: string;

/** CSS style for the button's container div */
containerStyle?: React.CSSProperties;

/** Whether or not the button should be diabled @default false */
disabled?: boolean;

/** Whether or not the button should be focusable @default false */
focusable?: boolean;

/** Child elements of the component */
children?: ReactNode;
}

/** Type of indicator to use when CustomButton is focused*/
export enum CustomButtonFocusMode {
highlight,
ring
}

/** CSS class names for CustomButton component */
export enum CustomButtonClasses {
buttonContainer = 'custom-button-container',
button = 'custom-button'
}

/** A button component with many customizable options */
export const CustomButton: FC<CustomButtonProps> = ({
audioSFX,
noAudio,
disabled,
focusable,
transparent,
focusMode,
onFocus,
onBlur,
onClick,
style,
className,
containerStyle,
containerClassName,
focusClassName,
onOKActionDescription,
children,
...focusableProps
}) => {
const [focused, setFocused] = useState(false);
const focusStyle = focusMode ?? CustomButtonFocusMode.highlight;

const audioPath: SFXPath = `/sounds/${audioSFX ?? 'deck_ui_default_activation.wav'}`;

const onClicked = (e: CustomEvent) => {
if (!disabled) {
!noAudio && GamepadUIAudio.AudioPlaybackManager.PlayAudioURL(audioPath);
onClick?.(e);
}
};

return (
<Focusable
//@ts-ignore
onClick={onClicked}
className={addClasses(CustomButtonClasses.buttonContainer, containerClassName)}
style={containerStyle}
onActivate={focusable ?? true ? onClicked : undefined}
onFocus={(e) => { setFocused(true); onFocus?.(e); }}
onBlur={(e) => { setFocused(false); onBlur?.(e); }}
noFocusRing={!(focusMode ?? false)}
onOKActionDescription={disabled ? '' : onOKActionDescription}
{...focusableProps}
>
<DialogButton
className={addClasses(CustomButtonClasses.button, className, focusStyle === CustomButtonFocusMode.highlight && focused && 'gpfocus', focused && focusClassName)}
style={Object.assign(transparent && (focusStyle === CustomButtonFocusMode.ring || !focused) ? { background: 'transparent' } : {}, style ?? {})}
focusable={false}
disabled={disabled}
>
{children}
</DialogButton>
</Focusable>
);
};

/** Utility function to join strings for CSS class names omitting invalid values */
function addClasses(...strings: any[]) {
return strings.filter(string => string).join(' ');
}
141 changes: 141 additions & 0 deletions src/custom-components/CustomDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { SingleDropdownOption, DropdownProps, showContextMenu, Menu, MenuItem, showModal } from '../deck-components';
import { ReactElement, VFC, useState, useEffect } from 'react';
import { BsThreeDots } from 'react-icons/bs';
import { CustomButtonProps, CustomButton } from './CustomButton';

export type BaseModalProps = {
onSelectOption: (option: SingleDropdownOption) => void,
rgOptions?: SingleDropdownOption[],
selectedOption?: SingleDropdownOption['data'],
closeModal?: () => void
}

export interface CustomDropdownProps extends Omit<DropdownProps, 'rgOptions' | 'onMenuWillOpen' | 'selectedOption' | 'contextMenuPositionOptions' | 'renderButtonValue'>, Omit<CustomButtonProps, 'audioSFX' | 'noAudio' | 'onClick' | 'children'> {
/** An array of options to choose from */
rgOptions?: SingleDropdownOption[];

/** The selected option data */
selectedOption?: SingleDropdownOption['data'];

/** Whether or not the selection label should be centered @default false */
labelCenter?: boolean;

/** A string to always show in place of the selected option's label */
labelOverride?: string;

/** Whether or not the selection dropdown arrow should be removed @default false */
noDropdownIcon?: boolean;

/** An element to use a replacement for the selection dropdown icon */
customDropdownIcon?: ReactElement;

/** A custom modal to use to select options instead of the default context menu */
useCustomModal?: VFC<BaseModalProps>;

/** CSS style for the selection label div */
labelStyle?: React.CSSProperties;

/** CSS style for the selection label div when it has changed */
labelChangedStyle?: React.CSSProperties;
}

/** CSS class names for CustomDropdown component */
export enum CustomDropdownClasses {
topLevel = 'custom-dropdown-container',
label = 'custom-dropdown-label',
selectionChanged = 'selection-changed'
}

/** A dropdown component with many customizable options */
export const CustomDropdown: VFC<CustomDropdownProps> = ({
rgOptions,
selectedOption: selectedOptionData,
style,
labelStyle,
labelChangedStyle,
containerClassName,
labelOverride,
strDefaultLabel,
labelCenter,
menuLabel,
noDropdownIcon,
customDropdownIcon,
focusMode,
transparent,
onChange,
useCustomModal: CustomModal,
onMenuOpened,
...buttonProps
}) => {
const icon = customDropdownIcon ?? (CustomModal ? <BsThreeDots style={{ margin: 'auto' }} /> : <svg style={{ height: '1em', margin: 'auto' }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" fill="none"><path d="M17.98 26.54L3.20996 11.77H32.75L17.98 26.54Z" fill="currentColor"></path></svg>);
const [selected, setSelected] = useState<SingleDropdownOption | undefined>(rgOptions?.find(option => option.data === selectedOptionData));
const [changed, setChanged] = useState(false);

useEffect(() => {
if (changed) {
setTimeout(() => setChanged(false), 15);
}
}, [changed]);

useEffect(() => {
if (selected?.data !== selectedOptionData) {
setChanged(true);
setSelected(rgOptions?.find(option => option.data === selectedOptionData));
}
}, [selectedOptionData, rgOptions?.length]);


const onSelect = (option: SingleDropdownOption) => {
setChanged(true);
setSelected(option);
onChange?.(option);
};

const showDefaultMenu = () => {
showContextMenu(<Menu label={menuLabel ?? ''} >
{rgOptions?.map(option =>
<MenuItem selected={option === selected} onClick={() => onSelect(option)}>
{option.label}
</MenuItem>)}
</Menu>);
onMenuOpened?.();
};

return (
<CustomButton
containerClassName={addClasses(CustomDropdownClasses.topLevel, containerClassName)}
style={{ padding: '10px 16px', ...style }}
noAudio={true}
focusMode={focusMode}
transparent={transparent}
onClick={() => {
CustomModal ? showModal(
<CustomModal
onSelectOption={(option) => onSelect(option)}
selectedOption={selected?.data}
rgOptions={rgOptions}
/>
) : rgOptions && showDefaultMenu();
}}
{...buttonProps}
>
<div style={{ display: 'flex', overflow: 'hidden' }}>
<div style={{ overflow: 'hidden', flex: 'auto' }}>
<div style={Object.assign({ textAlign: labelCenter ? 'center' : 'left', minHeight: '20px' }, changed ? labelChangedStyle : labelStyle)} className={addClasses(CustomDropdownClasses.label, changed && CustomDropdownClasses.selectionChanged)}>
{labelOverride ?? selected?.label ?? strDefaultLabel}
</div>
</div>
{!noDropdownIcon && (
<div style={{ display: 'flex', marginLeft: '1ch', flex: 'none' }}>
{icon}
</div>
)}
</div>
</CustomButton>
);
};

/** Utility function to join strings for CSS class names omitting invalid values */
function addClasses(...strings: any[]) {
return strings.filter(string => string).join(' ');
}
Loading