diff --git a/package.json b/package.json index b48d05fd..96ef62cd 100644 --- a/package.json +++ b/package.json @@ -78,5 +78,8 @@ "style": "module", "parser": "typescript" } + }, + "dependencies": { + "react-icons": "^4.11.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3df954cc..b0d92092 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,10 @@ lockfileVersion: '6.0' +dependencies: + react-icons: + specifier: ^4.11.0 + version: 4.11.0 + devDependencies: '@commitlint/cli': specifier: ^17.0.2 @@ -4388,6 +4393,15 @@ packages: strip-json-comments: 2.0.1 dev: true + /react-icons@4.11.0: + resolution: {integrity: sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA==} + 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/CustomButton.tsx b/src/custom-components/CustomButton.tsx new file mode 100644 index 00000000..8363650a --- /dev/null +++ b/src/custom-components/CustomButton.tsx @@ -0,0 +1,104 @@ +import { ReactNode, FC, useState } from "react"; +import { FocusableProps, Focusable, DialogButton } from '../deck-components'; +import { GamepadUIAudio, SFXPath, SoundFile, joinClassNames } from '../utils'; + +export interface CustomButtonProps extends Omit { + /** 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 = ({ + 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 ( + { setFocused(true); onFocus?.(e); }} + onBlur={(e) => { setFocused(false); onBlur?.(e); }} + noFocusRing={!(focusMode ?? false)} + onOKActionDescription={disabled ? '' : onOKActionDescription} + {...focusableProps} + > + + {children} + + + ); +}; diff --git a/src/custom-components/CustomDropdown.tsx b/src/custom-components/CustomDropdown.tsx new file mode 100644 index 00000000..762c8875 --- /dev/null +++ b/src/custom-components/CustomDropdown.tsx @@ -0,0 +1,140 @@ +import { SingleDropdownOption, DropdownProps, showContextMenu, Menu, MenuItem, showModal } from '../deck-components'; +import { ReactElement, VFC, useState, useEffect } from 'react'; +import { FaEllipsis } from 'react-icons/fa6'; +import { CustomButtonProps, CustomButton } from './CustomButton'; +import { joinClassNames } from '../utils'; + +export type BaseModalProps = { + onSelectOption: (option: SingleDropdownOption) => void, + rgOptions?: SingleDropdownOption[], + selectedOption?: SingleDropdownOption['data'], + closeModal?: () => void +} + +export interface CustomDropdownProps extends Omit, Omit { + /** 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 */ + customModal?: VFC; + + /** 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 = ({ + rgOptions, + selectedOption: selectedOptionData, + style, + labelStyle, + labelChangedStyle, + containerClassName, + labelOverride, + strDefaultLabel, + labelCenter, + menuLabel, + noDropdownIcon, + customDropdownIcon, + focusMode, + transparent, + onChange, + customModal: CustomModal, + onMenuOpened, + ...buttonProps +}) => { + const icon = customDropdownIcon ?? (CustomModal ? : ); + const [selected, setSelected] = useState(rgOptions?.find(option => option.data === selectedOptionData)); + const [changed, setChanged] = useState(false); + + useEffect(() => { + let timeout: number; + if (changed) { + timeout = setTimeout(() => setChanged(false), 15); + } + return () => clearTimeout(timeout); + }, [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( + {rgOptions?.map(option => + onSelect(option)}> + {option.label} + )} + ); + onMenuOpened?.(); + }; + + return ( + { + CustomModal ? showModal( + onSelect(option)} + selectedOption={selected?.data} + rgOptions={rgOptions} + /> + ) : rgOptions && showDefaultMenu(); + }} + {...buttonProps} + > +
+
+
+ {labelOverride ?? selected?.label ?? strDefaultLabel} +
+
+ {!noDropdownIcon && ( +
+ {icon} +
+ )} +
+
+ ); +}; + diff --git a/src/custom-components/DatePickers.tsx b/src/custom-components/DatePickers.tsx new file mode 100644 index 00000000..bb0617f8 --- /dev/null +++ b/src/custom-components/DatePickers.tsx @@ -0,0 +1,624 @@ +import { SingleDropdownOption, quickAccessMenuClasses, ConfirmModal, Focusable } from '../deck-components'; +import { SoundFile, afterPatch, joinClassNames } from '../utils'; +import { ReactElement, VFC, useMemo, useState, Fragment, useEffect } from 'react'; +import { FaRegCalendarAlt } from 'react-icons/fa'; +import { EnhancedSelector, EnhancedSelectorFocusRingMode, EnhancedSelectorTransparencyMode } from './EnhancedSelector'; +import { CustomDropdown } from './CustomDropdown'; +import { CustomButton, CustomButtonFocusMode } from './CustomButton'; + +export type DatePickerModalType = 'simple' | 'pretty'; +export type DateObj = { day?: number, month?: number, year: number; }; + +/** A SingleDropdownOption with specific date data */ +export interface DateSelection extends SingleDropdownOption { + data: DateObj; +}; + +/** Whether the date includes day/ month and year, month and year, or year only. */ +export enum DateIncludes { + yearOnly, + monthYear, + dayMonthYear +} + +/** Props for DatePicker component */ +export interface DatePickerProps extends Omit { + /** The date picker modal style. Either "simple" or "pretty" @default 'simple' */ + modalType?: DatePickerModalType; + + /** Callback function to call when the date is changed */ + onChange?: (date: DateSelection) => void; + + /** Custom icon to use for the popup button */ + buttonIcon?: ReactElement; + + /** Whether or not the popup button icon should be removed @default false */ + noIcon?: boolean; + + /** Whether or not the popup button text chould be centered @default false */ + buttonLabelCenter?: boolean; + + /** CSS style for the popup button */ + buttonStyle?: React.CSSProperties; + + /** CSS style for the popup button container */ + buttonContainerStyle?: React.CSSProperties; + + /** Default text to display in the popup button when selectedDate is undefined or invalid @default 'Select Date...' */ + strDefaultLabel?: string; +} + +/** CSS class names for DatePicker component */ +export enum DatePickerClasses { + topLevel = 'date-picker' +} + +interface ModalWrapperProps { + onSelectOption: (option: SingleDropdownOption) => void; + selectedOption?: any; + rgOptions?: any; + closeModal?: () => void; +} + +/** A highly configurable button component that pops up a modal to select a date and displays the captured result. */ +export const DatePicker: VFC = ({ + modalType, + selectedDate, + buttonLabelCenter, + buttonIcon, + noIcon, + buttonStyle, + buttonContainerStyle, + strDefaultLabel, + toLocaleStringOptions, + dateIncludes, + onChange, + ...modalProps +}) => { + const DatePickerModal = modalType === 'pretty' ? PrettyDatePickerModal : SimpleDatePickerModal; + const include = dateIncludes ?? DateIncludes.dayMonthYear; + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const { day: incomingDay, month: incomingMonth, year: incomingYear } = selectedDate ?? {}; + + const [day, setDay] = useState(include === DateIncludes.dayMonthYear ? incomingDay ?? 1 : undefined); + const [month, setMonth] = useState(include >= DateIncludes.monthYear ? incomingMonth ?? 1 : undefined); + const [year, setYear] = useState(incomingYear); + + const valid = isValidDate(day, month, year); + + useEffect(() => { + if (mounted) { + const { day, month, year } = selectedDate ?? {}; + const valid = selectedDate && isValidDate(day, month, year); + if (valid) { + setYear(year); + setMonth(month); + setDay(day); + } + } + }, [incomingDay, incomingMonth, incomingYear]); + + const _date: DateObj = { year: year! }; + if (month) _date.month = month; + if (day) _date.day = day; + + const date = useMemo(() => valid ? _date : undefined, [day, month, year, valid, include]); + + const options = useMemo(() => valid ? [{ + label: dateToLabel(year!, month, day, toLocaleStringOptions), + data: date! + }] : undefined, [day, month, year, valid, include]); + + useEffect(() => { + if (valid && mounted) { + const newDate = { ...options![0].data }; + + switch (include) { + case DateIncludes.dayMonthYear: + if (!newDate.day) newDate.day = 1; + if (!newDate.month) newDate.month = 1; + break; + case DateIncludes.monthYear: + if (!newDate.month) newDate.month = 1; + delete newDate.day; + break; + case DateIncludes.yearOnly: + delete newDate.day; + delete newDate.month; + } + setYear(newDate.year); + setMonth(newDate.month); + setDay(newDate.day); + onChange?.({ label: dateToLabel(newDate.year!, newDate.month, newDate.day, toLocaleStringOptions), data: newDate }); + } + }, [include]); + + return ( + } + noDropdownIcon={noIcon ?? false} + style={buttonStyle} + containerStyle={buttonContainerStyle} + containerClassName={DatePickerClasses.topLevel} + customModal={({ onSelectOption, selectedOption, closeModal }: ModalWrapperProps) => { + return { + onSelectOption(date); + setYear(date.data.year); + setMonth(date.data.month); + setDay(date.data.day); + }} + selectedDate={selectedOption} + dateIncludes={include} + toLocaleStringOptions={toLocaleStringOptions} + closeModal={closeModal} + {...modalProps} + />; + }} + /> + ); +}; + +/** Props for SimpleDatePickerModal and PrettyDatePickerModal component */ +export interface DatePickerModalProps { + /** Callback function to call when a date is selected */ + onSelectDate?: (date: DateSelection) => void; + + /** The selected date */ + selectedDate?: DateObj; + + /** The earliest year that will be available to select from. This get overridden if selectedDate year is earlier @default 1970 */ + startYear?: number; + + /** The latest year that will be available to select from. This get overridden if selectedDate year is later. Default is current year. */ + endYear?: number; + + /** The options for the date string format */ + toLocaleStringOptions?: Intl.DateTimeFormatOptions; + + /** Whether the date includes day/ month and year, month and year, or year only. @default dayMonthYear*/ + dateIncludes?: DateIncludes; + + /** Whether or not the day/ month/ year selector labels should be centered @default true */ + centerSelectorLabels?: boolean; + + /** Whether the day/ month/ year selections should stop when at the end/ beginning of the list or cycle around @default false */ + noWrapAround?: boolean; + + /** Whether or not the day/ month/ year selection boxes should be focusable @default true */ + focusDropdowns?: boolean; + + /** Whether or not to show the dropdown arrow on the day/ month/ year selection boxes @default false */ + showDropdownIcons?: boolean; + + /** Which elements of the day/ month/ year EnhancedSelectors should be transparent @default none */ + transparencyMode?: EnhancedSelectorTransparencyMode; + + /** When to use focus ring instead of highlight when focusing an element @default never */ + focusRingMode?: EnhancedSelectorFocusRingMode; + + /** Whether or not to use the alternate sound effects for the EnhancedSelectors @default false */ + alternateSFX?: boolean; + + /** Sound effect override to use for the day/ month/ year EnhancedSelector buttons */ + sfxMain?: SoundFile; + + /** Sound effect override to use on day/ month/ year buttons for when at the end/ beginning of the list and noWrapAround is true */ + sfxInvalid?: SoundFile; + + /** Whether or not the day/ month/ year selection boxes should animate when shifting the selection @default true */ + animate?: boolean; + + /** The duration in ms of the day/ month/ year selection box animations @default 300 */ + animationDuration?: number; + + /** Function for closing the modal, typically automatically passed when showModal is called. */ + closeModal?: () => void; +} + +export enum DatePickerModalClasses { + topLevel = 'date-picker-modal', + title = 'date-picker-modal-title', + pretty = 'date-picker-pretty' +} + +/** A visually simple date picker modal that is configurable */ +export const SimpleDatePickerModal: VFC = ({ + onSelectDate, + selectedDate, + toLocaleStringOptions, + focusDropdowns, + showDropdownIcons, + centerSelectorLabels, + startYear, + endYear, + dateIncludes, + closeModal, + ...selectorProps +}) => { + const thisYear = new Date().getUTCFullYear(); + + const { day: incomingDay, month: incomingMonth, year: incomingYear } = (selectedDate ?? {}); + + const include = dateIncludes ?? DateIncludes.dayMonthYear; + + const [day, setDay] = useState(include === DateIncludes.dayMonthYear ? incomingDay ?? 1 : undefined); + const [month, setMonth] = useState(include >= DateIncludes.monthYear ? incomingMonth ?? 1 : undefined); + const [year, setYear] = useState(incomingYear ?? thisYear); + + const start = startYear ?? 1970; + const end = endYear ?? thisYear; + + const dayOptions = useMemo(() => getDayOptions(), []); + const monthOptions = useMemo(() => getMonthOptions(), []); + const yearOptions = useMemo(() => getYearOptions(year < start ? year : start, year > end ? year : end), []); + + const onConfirm = () => { + const label = dateToLabel(year, month, day, toLocaleStringOptions); + const date: DateObj = { year: year }; + if (day) date.day = day; + if (month) date.month = month; + onSelectDate?.({ label: label, data: date }); + }; + + const daySelector = useMemo(() => { + if (!day) return; + const daysInMonth = getDaysInMonth(month!, year); + let _day = day; + if (day > daysInMonth) { + _day = daysInMonth; + setDay(daysInMonth); + } + return setDay(option.data)} + focusDropdown={focusDropdowns ?? true} + showDropdownIcon={showDropdownIcons} + labelCenter={centerSelectorLabels ?? true} + {...selectorProps} + rgOptions={dayOptions.slice(0, daysInMonth)} + selectedOption={_day} + />; + }, [month, year]); + + const monthSelector = useMemo(() => { + if (!month) return; + return setMonth(option.data)} + focusDropdown={focusDropdowns ?? true} + showDropdownIcon={showDropdownIcons} + labelCenter={centerSelectorLabels ?? true} + {...selectorProps} + rgOptions={monthOptions} + selectedOption={month} + />; + }, []); + + const yearSelector = useMemo(() => { + return setYear(option.data)} + focusDropdown={focusDropdowns ?? true} + showDropdownIcon={showDropdownIcons} + labelCenter={centerSelectorLabels ?? true} + {...selectorProps} + rgOptions={yearOptions} + selectedOption={year} + />; + }, []); + + const titleStyle = { justifyContent: 'center' }; + const sectionStyle = day ? {} : { flex: '1' }; + const titleClass = joinClassNames(quickAccessMenuClasses.PanelSectionTitle, DatePickerModalClasses.title); + + return ( + + + + + {month && +
+
+ Month +
+ {monthSelector} +
} + {day && +
+
+ Day +
+ {daySelector} +
} +
+
+ Year +
+ {yearSelector} +
+
+
+
+ ); +}; + +/** A nice looking date picker modal that is configurable */ +export const PrettyDatePickerModal: VFC = ({ + onSelectDate, + selectedDate, + toLocaleStringOptions, + focusDropdowns, + showDropdownIcons, + centerSelectorLabels, + startYear, + endYear, + dateIncludes, + closeModal, + ...selectorProps +}) => { + const thisYear = new Date().getUTCFullYear(); + + const { day: incomingDay, month: incomingMonth, year: incomingYear } = (selectedDate ?? {}); + + const include = dateIncludes ?? DateIncludes.dayMonthYear; + + const [day, setDay] = useState(include === DateIncludes.dayMonthYear ? incomingDay ?? 1 : undefined); + const [month, setMonth] = useState(include >= DateIncludes.monthYear ? incomingMonth ?? 1 : undefined); + const [year, setYear] = useState(incomingYear ?? thisYear); + + const start = startYear ?? 1970; + const end = endYear ?? thisYear; + + const monthOptions = useMemo(() => getMonthOptions(), []); + const yearOptions = useMemo(() => getYearOptions(year < start ? year : start, year > end ? year : end), []); + + const onConfirm = () => { + const label = dateToLabel(year, month, day, toLocaleStringOptions); + const date: DateObj = { year: year }; + if (day) date.day = day; + if (month) date.month = month; + onSelectDate?.({ label: label, data: date }); + }; + + const monthSelector = useMemo(() => { + return setMonth(option.data)} + focusDropdown={focusDropdowns ?? true} + showDropdownIcon={showDropdownIcons} + labelCenter={centerSelectorLabels ?? true} + {...selectorProps} + rgOptions={monthOptions} + selectedOption={month ?? 1} + disabled={!month} + />; + }, []); + + const yearSelector = useMemo(() => { + return setYear(option.data)} + focusDropdown={focusDropdowns ?? true} + showDropdownIcon={showDropdownIcons} + labelCenter={centerSelectorLabels ?? true} + {...selectorProps} + rgOptions={yearOptions} + selectedOption={year} + />; + }, []); + + const daysInMonth = useMemo(() => getDaysInMonth(month ?? 1, year), [month, year]); + + const titleStyle = { justifyContent: 'center' }; + const titleClass = joinClassNames(quickAccessMenuClasses.PanelSectionTitle, DatePickerModalClasses.title); + + const focusable = +
+
+ Month +
+ {monthSelector} +
+
+
+ Year +
+ {yearSelector} +
+
; + + const ancestorNode = useMemo(() => { + const container: { navNode: any; } = { navNode: undefined }; + afterPatch(focusable.type, 'render', (_: any, ret: any) => { + container.navNode = ret.props.value; + return ret; + }, { singleShot: true }); + return container; + }, []); + + useEffect(() => { + let timeout: number; + const parentNavNode = ancestorNode.navNode?.m_rgChildren[0]?.m_rgChildren[0]?.m_rgChildren[0]; + if (!parentNavNode || !parentNavNode.m_rgChildren[0] || !parentNavNode.m_rgChildren[1]) { + console.log('Date picker modal could not find focus nav nodes'); + } else { + timeout = setTimeout(() => { + parentNavNode.m_rgChildren[0].SetProperties({ navEntryPreferPosition: 2 }); + parentNavNode.m_rgChildren[1].SetProperties({ navEntryPreferPosition: 2 }); + }, 10); + } + return () => clearTimeout(timeout); + }, [day, daysInMonth, month, year]); + + return ( + + + + {focusable} + + + + ); +}; + +interface CalendarPanelProps { + selectedDay?: number; + daysInMonth: number; + disabled: boolean; + onChange: (day: number) => void; +} +const CalendarPanel: VFC = ({ selectedDay, daysInMonth, disabled, onChange }) => { + const grid = useMemo(() => { + const dayElts = []; + for (let i = 0; i < daysInMonth; i++) { + const day = i + 1; + const selectedStyle = selectedDay === day ? { background: '#a8b4ee2e' } : {}; + dayElts.push( + onChange(day)} + style={{ minWidth: '40px', margin: 'auto', padding: '10px 0', fontSize: '13px', ...selectedStyle }} + focusMode={CustomButtonFocusMode.ring} + transparent={true} + disabled={disabled} + focusable={!disabled} + audioSFX='deck_ui_switch_toggle_on.wav' + > + {day} + + ); + } + return ( + + {dayElts} + + ); + }, [daysInMonth, selectedDay]); + + return grid; +}; + +const locales = window.LocalizationManager.m_rgLocalesToUse; + +function getDayOptions() { + const dayOptions: SingleDropdownOption[] = []; + for (let i = 1; i <= 31; i++) { + dayOptions.push({ label: i, data: i }); + } + return dayOptions; +} + +function getMonthOptions() { + const monthOptions: SingleDropdownOption[] = []; + for (let i = 1; i <= 12; i++) { + monthOptions.push({ label: new Date(2000, i - 1).toLocaleDateString(locales, { month: 'long' }), data: i }); + } + return monthOptions; +} + +function getYearOptions(beginning: number, end: number) { + const yearOptions = []; + for (let i = beginning; i <= end; i++) { + yearOptions.push({ label: i, data: i }); + } + return yearOptions; +} + +function getDaysInMonth(month: number, year: number) { + return new Date(year, month, 0).getDate(); +} + +export function dateToLabel(year: number, month?: number, day?: number, formatOptions?: Intl.DateTimeFormatOptions) { + const defaultOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric' + }; + + let _options: Intl.DateTimeFormatOptions; + + if (formatOptions?.dateStyle) { + switch (formatOptions?.dateStyle) { + case 'full': + _options = { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }; + break; + + case 'long': + _options = { + year: 'numeric', + month: 'long', + day: 'numeric' + }; + break; + + case 'medium': + _options = { + year: 'numeric', + month: 'short', + day: 'numeric' + }; + break; + + case 'short': + _options = { + year: '2-digit', + month: 'numeric', + day: 'numeric' + }; + break; + + default: + _options = defaultOptions; + } + } else { + _options = formatOptions ?? defaultOptions; + } + + if (month === undefined) { + delete _options.month; + } + if (day === undefined) { + delete _options.day; + delete _options.weekday; + } + + const date = new Date(year, (month ?? 1) - 1, day ?? 1).toLocaleDateString(locales, _options); + return date; +} + +function isValidDate(day?: number, month?: number, year?: number) { + if ((year === undefined) || + (day !== undefined && month === undefined) || + (month !== undefined && (month < 1 || month > 12)) || + (day !== undefined && (day < 1 || day > getDaysInMonth(month!, year)))) { + return false; + } + return true; +} + diff --git a/src/custom-components/EnhancedSelector.tsx b/src/custom-components/EnhancedSelector.tsx new file mode 100644 index 00000000..8d8ba0eb --- /dev/null +++ b/src/custom-components/EnhancedSelector.tsx @@ -0,0 +1,263 @@ +import { SingleDropdownOption, Focusable } from '../deck-components'; +import { Fragment, VFC, useEffect, useMemo, useState } from "react"; +import { FaChevronRight } from "react-icons/fa"; +import { CustomButton, CustomButtonFocusMode } from "./CustomButton"; +import { CustomDropdown, CustomDropdownProps } from './CustomDropdown'; +import { SoundFile, joinClassNames } from '../utils'; + +const defaultSFX = 'deck_ui_tab_transition_01.wav'; +const defaultInvalidSFX = 'deck_ui_bumper_end_02.wav'; +const altSFX = 'deck_ui_misc_01.wav'; +const altInvalidSFX = 'deck_ui_message_toast.wav'; + +/** Props for EnhancedSelector component */ +export interface EnhancedSelectorProps extends Omit { + /** An array of options to choose from */ + rgOptions: SingleDropdownOption[]; + + /** The selected option data */ + selectedOption: SingleDropdownOption['data']; + + /** Whether the selection should stop when its at the end/ beginning of the list or cycle around @default false */ + noWrapAround?: boolean; + + /** Whether or not selection box should be focusable @default false */ + focusDropdown?: boolean; + + /** Whether or not to show the dropdown arrow on the selection box @default false */ + showDropdownIcon?: boolean; + + /** Sets the width for the selection box, overrides everything else (should be a valid html width) @default auto */ + selectionBoxWidth?: string; + + /** Sets the component to take full width of of it containing element @default false */ + fullWidth?: boolean; + + /** Sets the spacing between elements (should be a valid html size) @default '12px' */ + spacing?: string; + + /** Sets the width for the buttons (should be a valid html width) @default '40px' */ + buttonWidth?: string; + + /** Which elements should be transparent @default none */ + transparencyMode?: EnhancedSelectorTransparencyMode; + + /** When to use focus ring instead of highlight when focusing an element @default never */ + focusRingMode?: EnhancedSelectorFocusRingMode; + + /** Whether or not to use the alternate sound effects @default false */ + alternateSFX?: boolean; + + /** Sound effect override to use for the normal button sound*/ + sfxMain?: SoundFile; + + /** Sound effect override to use for buttons when at the end/ beginning of the list and noWrapAround is true*/ + sfxInvalid?: SoundFile; + + /** Whether or not the selection box should animate when shifting the selection @default false */ + animate?: boolean; + + /** The duration in ms of the selection box animation @default 300 */ + animationDuration?: number; + + /** Whether or not the EnhancedSelector should be disabled */ + disabled?: boolean; +} + +/** Mode for which elements should have transparency in EnhancedSelector component*/ +export enum EnhancedSelectorTransparencyMode { + /** No elements have transparency*/ + none, + + /** Selection box has transparency, buttons don't*/ + selection, + + /** All elements have transparency */ + all, + + /** Buttons have transparency, selection box doesn't */ + buttons +} + +/** Mode for when to use focus ring vs highlight when focusing an element in EnhancedSelector component */ +export enum EnhancedSelectorFocusRingMode { + /** Always use highlight and not ring */ + never, + + /** Use ring for transparent elements and highlight otherwise */ + transparentOnly, + + /** Always use ring and not highlight */ + always +} + +/** CSS class names for EnhanceSelector component */ +export enum EnhancedSelectorClasses { + topLevel = 'enhanced-selector', + dirIcon = 'direction-icon', + dirButton = 'direction-button', + right = 'direction-right', + left = 'direction-left' +} + +/** A configurable component that allows to select from a list of options by cycling with buttons or from a dropdown menu. */ +export const EnhancedSelector: VFC = ({ + rgOptions, + selectedOption: selectedOptionData, + onChange, + noWrapAround, + showDropdownIcon, + focusDropdown, + transparencyMode, + fullWidth, + selectionBoxWidth, + spacing, + buttonWidth, + focusRingMode, + alternateSFX, + sfxMain, + sfxInvalid, + animate, + animationDuration, + disabled, + ...dropdownProps +}) => { + type direction = -1 | 1; // -1: left/negative, 1: right/postive + + const noWrap = noWrapAround ?? false; + const setWidth = selectionBoxWidth !== undefined; + const transparency = transparencyMode ?? EnhancedSelectorTransparencyMode.none; + const transparentButtons = transparency === EnhancedSelectorTransparencyMode.buttons || transparency === EnhancedSelectorTransparencyMode.all; + const transparentSelectionBox = transparency === EnhancedSelectorTransparencyMode.selection || transparency === EnhancedSelectorTransparencyMode.all; + const ringMode = focusRingMode ?? EnhancedSelectorFocusRingMode.never; + + const mainSfx = sfxMain ?? alternateSFX ? altSFX : defaultSFX; + const invalidSfx = sfxInvalid ?? alternateSFX ? altInvalidSFX : defaultInvalidSFX; + + const getFocusRingMode = (transparent: boolean) => { + switch (ringMode) { + case EnhancedSelectorFocusRingMode.always: + return CustomButtonFocusMode.ring; + case EnhancedSelectorFocusRingMode.never: + return CustomButtonFocusMode.highlight; + case EnhancedSelectorFocusRingMode.transparentOnly: + return transparent ? CustomButtonFocusMode.ring : CustomButtonFocusMode.highlight; + } + }; + + const selectionBoxFocusMode = getFocusRingMode(transparentSelectionBox); + const buttonFocusMode = getFocusRingMode(transparentButtons); + + const incomingIndex = useMemo(() => { + const index = rgOptions.findIndex(option => option.data === selectedOptionData); + return index !== -1 ? index : 0; + }, [selectedOptionData, rgOptions.length]); + + const [selectedIndex, setSelecetedIndex] = useState(incomingIndex); + const [animateLabelStart, setAnimateLabelStart] = useState({}); + + useEffect(() => { + if (selectedIndex !== incomingIndex) setSelecetedIndex(incomingIndex); + }, [selectedOptionData, rgOptions.length]); + + const getSFX = (dir: direction) => (noWrap && ((selectedIndex === rgOptions.length - 1 && dir === 1) || (selectedIndex === 0 && dir === -1))) ? invalidSfx : mainSfx; + + const getNewIndex = (current: number, dir: direction) => { + const max = rgOptions.length; + if (dir > 0) { + let newIndex = (current + 1) % max; + return newIndex === 0 && noWrap ? max - 1 : newIndex; + } else { + let newIndex = current - 1; + return newIndex < 0 ? (!noWrap ? max - 1 : 0) : newIndex; + } + }; + + const shiftIndex = (dir: direction) => { + const newIndex = getNewIndex(selectedIndex, dir); + if (newIndex !== selectedIndex) { + setSelecetedIndex(newIndex); + animate && setAnimateLabelStart({ transform: `translate(${100 * dir}%)` }); + onChange?.(rgOptions[newIndex]); + } + }; + + const onDropdownSelect = (option: { label: string, data: any; }) => { + const index = rgOptions.indexOf(option); + setSelecetedIndex(index); + animate && setAnimateLabelStart({}); + onChange?.(rgOptions[index]); + }; + + const buttonMargin = spacing ? spacing : '12px'; + + const buttonStyle = { + width: buttonWidth ? buttonWidth : '40px', + minHeight: '40px', + minWidth: 'initial', + padding: 'initial' + }; + + const buttonIconStyle = { + height: '.8em', + width: '.8em', + display: 'block', + margin: 'auto' + }; + + return ( + + + shiftIndex(-1)} + className={joinClassNames(EnhancedSelectorClasses.dirButton, EnhancedSelectorClasses.left)} + containerStyle={{ marginRight: buttonMargin }} + transparent={transparentButtons} + focusMode={buttonFocusMode} + style={buttonStyle} + disabled={disabled} + focusable={!disabled} + > + + + + shiftIndex(1)} + className={joinClassNames(EnhancedSelectorClasses.dirButton, EnhancedSelectorClasses.right)} + containerStyle={{ marginLeft: buttonMargin }} + transparent={transparentButtons} + focusMode={buttonFocusMode} + style={buttonStyle} + disabled={disabled} + focusable={!disabled} + > + + + + ); +}; diff --git a/src/custom-components/index.ts b/src/custom-components/index.ts index 94d0a7b2..8e643c44 100644 --- a/src/custom-components/index.ts +++ b/src/custom-components/index.ts @@ -1,3 +1,7 @@ export * from './SuspensefulImage'; export * from './ColorPickerModal'; export * from './ReorderableList'; +export * from './CustomButton'; +export * from './CustomDropdown'; +export * from './DatePickers'; +export * from './EnhancedSelector'; \ No newline at end of file diff --git a/src/utils/GamepadUIAudio.ts b/src/utils/GamepadUIAudio.ts new file mode 100644 index 00000000..17165ef7 --- /dev/null +++ b/src/utils/GamepadUIAudio.ts @@ -0,0 +1,108 @@ +import { Module, findModuleChild } from '../webpack'; + +export type NavSound = +'LaunchGame' | +'FriendMessage' | +'ChatMention' | +'ChatMessage' | +'ToastMessage' | +'ToastAchievement' | +'ToastMisc' | +'FriendOnline' | +'FriendInGame' | +'VolSound' | +'ShowModal' | +'HideModal' | +'IntoGameDetail' | +'OutOfGameDetail' | +'PagedNavigation' | +'ToggleOn' | +'ToggleOff' | +'SliderUp' | +'SliderDown' | +'ChangeTabs' | +'DefaultOk' | +'OpenSideMenu' | +'CloseSideMenu' | +'BasicNav' | +'FailedNav' | +'Typing'; + +export type SoundFile = + "bumper_end.wav" | + "confirmation_negative.wav" | + "confirmation_positive.wav" | + "deck_ui_achievement_toast.wav" | + "deck_ui_bumper_end_02.wav" | + "deck_ui_default_activation.wav" | + "deck_ui_hide_modal.wav" | + "deck_ui_into_game_detail.wav" | + "deck_ui_launch_game.wav" | + "deck_ui_message_toast.wav" | + "deck_ui_misc_01.wav" | + "deck_ui_misc_08.wav" | + "deck_ui_misc_10.wav" | + "deck_ui_navigation.wav" | + "deck_ui_out_of_game_detail.wav" | + "deck_ui_show_modal.wav" | + "deck_ui_side_menu_fly_in.wav" | + "deck_ui_side_menu_fly_out.wav" | + "deck_ui_slider_down.wav" | + "deck_ui_slider_up.wav" | + "deck_ui_switch_toggle_off.wav" | + "deck_ui_switch_toggle_on.wav" | + "deck_ui_tab_transition_01.wav" | + "deck_ui_tile_scroll.wav" | + "deck_ui_toast.wav" | + "deck_ui_typing.wav" | + "deck_ui_volume.wav" | + "pop_sound.wav" | + "steam_at_mention.m4a" | + "steam_chatroom_notification.m4a" | + "ui_steam_message_old_smooth.m4a" | + "ui_steam_smoother_friend_join.m4a" | + "ui_steam_smoother_friend_online.m4a"; + +export type SFXPath = `/sounds/${SoundFile}` + +export interface PlaybackObject { + url: SFXPath; + StopPlayback: () => void; + OnFailure: () => void; + OnPlaybackEnded: () => void; + NotifyPlaybackFinished: () => void; + RegisterOnPlaybackFinished: (callback: () => void) => void; +} + +export interface GamepadUIAudio { + AudioPlaybackManager: AudioPlaybackManager; + PlayNavSound: (navSound: NavSound) => void; + PlayNavSoundInternal: (navSound: NavSound) => void; + PlayAudioURL: (path: SFXPath) => PlaybackObject; +} + +export interface AudioPlaybackManager { + context: AudioContext; + supports_audio_worklets: boolean; + PlayAudioURL: (path: SFXPath) => PlaybackObject; + PlayAudioURLWithRepeats: (path: SFXPath, nTimes: number) => PlaybackObject; + SetVoiceStore: (voiceStore: unknown) => void; + GetActiveDestination: () => AudioContext['destination']; + PlaybackFinished: (playbackObj: PlaybackObject) => void; + SetVoiceActive: (unknownCB?: () => void) => void; + SetVoiceNotActive: () => void; + GetLastObservedSampleRate: () => AudioContext['sampleRate']; + CreateContextIfNeeded: (unknownCB?: () => void) => void; + DelayedCleanupContextIfInactive: () => void; + CleanupContextIfUneeded: (delayCleanup: boolean) => void; + OnAudioContextStateChange: () => void; +} + +export const GamepadUIAudio: GamepadUIAudio = findModuleChild((m: Module) => { + if (typeof m !== "object") return undefined; + for (let prop in m) { + if (m[prop]?.GamepadUIAudio) { + return m[prop].GamepadUIAudio; + } + } +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index a1fd37d0..e7665891 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,13 +1,17 @@ export * from './patcher'; export * from './react'; +export * from './GamepadUIAudio'; declare global { var FocusNavController: any; var GamepadNavTree: any; } -export function joinClassNames(...classes: string[]): string { - return classes.join(' '); +/** + * Join strings for CSS class names omitting falsy values + */ +export function joinClassNames(...classes: any[]): string { + return classes.filter(value => value).join(' '); } export function sleep(ms: number) {