@decky/ui 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +504 -0
  2. package/README.md +30 -0
  3. package/dist/class-mapper.d.ts +7 -0
  4. package/dist/class-mapper.js +26 -0
  5. package/dist/components/Button.d.ts +5 -0
  6. package/dist/components/Button.js +2 -0
  7. package/dist/components/ButtonItem.d.ts +7 -0
  8. package/dist/components/ButtonItem.js +5 -0
  9. package/dist/components/Carousel.d.ts +19 -0
  10. package/dist/components/Carousel.js +2 -0
  11. package/dist/components/ControlsList.d.ts +7 -0
  12. package/dist/components/ControlsList.js +2 -0
  13. package/dist/components/Dialog.d.ts +33 -0
  14. package/dist/components/Dialog.js +24 -0
  15. package/dist/components/DialogCheckbox.d.ts +17 -0
  16. package/dist/components/DialogCheckbox.js +14 -0
  17. package/dist/components/Dropdown.d.ts +34 -0
  18. package/dist/components/Dropdown.js +5 -0
  19. package/dist/components/Field.d.ts +23 -0
  20. package/dist/components/Field.js +2 -0
  21. package/dist/components/FocusRing.d.ts +9 -0
  22. package/dist/components/FocusRing.js +2 -0
  23. package/dist/components/Focusable.d.ts +12 -0
  24. package/dist/components/Focusable.js +4 -0
  25. package/dist/components/FooterLegend.d.ts +66 -0
  26. package/dist/components/FooterLegend.js +32 -0
  27. package/dist/components/Item.d.ts +12 -0
  28. package/dist/components/Item.js +1 -0
  29. package/dist/components/Marquee.d.ts +14 -0
  30. package/dist/components/Marquee.js +2 -0
  31. package/dist/components/Menu.d.ts +29 -0
  32. package/dist/components/Menu.js +9 -0
  33. package/dist/components/Modal.d.ts +52 -0
  34. package/dist/components/Modal.js +29 -0
  35. package/dist/components/Panel.d.ts +11 -0
  36. package/dist/components/Panel.js +4 -0
  37. package/dist/components/ProgressBar.d.ts +21 -0
  38. package/dist/components/ProgressBar.js +6 -0
  39. package/dist/components/Scroll.d.ts +7 -0
  40. package/dist/components/Scroll.js +5 -0
  41. package/dist/components/SidebarNavigation.d.ts +21 -0
  42. package/dist/components/SidebarNavigation.js +4 -0
  43. package/dist/components/SliderField.d.ts +26 -0
  44. package/dist/components/SliderField.js +2 -0
  45. package/dist/components/Spinner.d.ts +2 -0
  46. package/dist/components/Spinner.js +2 -0
  47. package/dist/components/SteamSpinner.d.ts +2 -0
  48. package/dist/components/SteamSpinner.js +2 -0
  49. package/dist/components/Tabs.d.ts +16 -0
  50. package/dist/components/Tabs.js +48 -0
  51. package/dist/components/TextField.d.ts +22 -0
  52. package/dist/components/TextField.js +2 -0
  53. package/dist/components/Toggle.d.ts +8 -0
  54. package/dist/components/Toggle.js +2 -0
  55. package/dist/components/ToggleField.d.ts +9 -0
  56. package/dist/components/ToggleField.js +2 -0
  57. package/dist/components/index.d.ts +25 -0
  58. package/dist/components/index.js +25 -0
  59. package/dist/custom-components/ColorPickerModal.d.ts +12 -0
  60. package/dist/custom-components/ColorPickerModal.js +87 -0
  61. package/dist/custom-components/ReorderableList.d.ts +26 -0
  62. package/dist/custom-components/ReorderableList.js +84 -0
  63. package/dist/custom-components/SuspensefulImage.d.ts +7 -0
  64. package/dist/custom-components/SuspensefulImage.js +27 -0
  65. package/dist/custom-components/index.d.ts +3 -0
  66. package/dist/custom-components/index.js +3 -0
  67. package/dist/custom-hooks/index.d.ts +1 -0
  68. package/dist/custom-hooks/index.js +1 -0
  69. package/dist/custom-hooks/useQuickAccessVisible.d.ts +1 -0
  70. package/dist/custom-hooks/useQuickAccessVisible.js +22 -0
  71. package/dist/deck-hooks/index.d.ts +1 -0
  72. package/dist/deck-hooks/index.js +1 -0
  73. package/dist/deck-hooks/useParams.d.ts +1 -0
  74. package/dist/deck-hooks/useParams.js +2 -0
  75. package/dist/globals/SteamClient.d.ts +307 -0
  76. package/dist/globals/SteamClient.js +1 -0
  77. package/dist/globals/index.d.ts +2 -0
  78. package/dist/globals/index.js +2 -0
  79. package/dist/globals/stores.d.ts +50 -0
  80. package/dist/globals/stores.js +1 -0
  81. package/dist/index.d.ts +10 -0
  82. package/dist/index.js +14 -0
  83. package/dist/logger.d.ts +17 -0
  84. package/dist/logger.js +46 -0
  85. package/dist/modules/Router.d.ts +110 -0
  86. package/dist/modules/Router.js +102 -0
  87. package/dist/modules/index.d.ts +1 -0
  88. package/dist/modules/index.js +1 -0
  89. package/dist/utils/index.d.ts +12 -0
  90. package/dist/utils/index.js +23 -0
  91. package/dist/utils/patcher.d.ts +18 -0
  92. package/dist/utils/patcher.js +103 -0
  93. package/dist/utils/react.d.ts +19 -0
  94. package/dist/utils/react.js +89 -0
  95. package/dist/utils/static-classes.d.ts +52 -0
  96. package/dist/utils/static-classes.js +28 -0
  97. package/dist/webpack.d.ts +21 -0
  98. package/dist/webpack.js +102 -0
  99. package/package.json +89 -0
  100. package/src/class-mapper.ts +34 -0
  101. package/src/components/Button.ts +8 -0
  102. package/src/components/ButtonItem.ts +16 -0
  103. package/src/components/Carousel.ts +25 -0
  104. package/src/components/ControlsList.ts +14 -0
  105. package/src/components/Dialog.ts +90 -0
  106. package/src/components/DialogCheckbox.ts +36 -0
  107. package/src/components/Dropdown.ts +51 -0
  108. package/src/components/Field.ts +29 -0
  109. package/src/components/FocusRing.ts +15 -0
  110. package/src/components/Focusable.ts +21 -0
  111. package/src/components/FooterLegend.ts +67 -0
  112. package/src/components/Item.ts +13 -0
  113. package/src/components/Marquee.ts +20 -0
  114. package/src/components/Menu.ts +58 -0
  115. package/src/components/Modal.ts +119 -0
  116. package/src/components/Panel.ts +26 -0
  117. package/src/components/ProgressBar.ts +37 -0
  118. package/src/components/Scroll.ts +15 -0
  119. package/src/components/SidebarNavigation.ts +30 -0
  120. package/src/components/SliderField.ts +33 -0
  121. package/src/components/Spinner.ts +8 -0
  122. package/src/components/SteamSpinner.ts +7 -0
  123. package/src/components/Tabs.tsx +127 -0
  124. package/src/components/TextField.ts +28 -0
  125. package/src/components/Toggle.ts +14 -0
  126. package/src/components/ToggleField.ts +15 -0
  127. package/src/components/index.ts +25 -0
  128. package/src/custom-components/ColorPickerModal.tsx +132 -0
  129. package/src/custom-components/ReorderableList.tsx +183 -0
  130. package/src/custom-components/SuspensefulImage.tsx +44 -0
  131. package/src/custom-components/index.ts +3 -0
  132. package/src/custom-hooks/index.ts +1 -0
  133. package/src/custom-hooks/useQuickAccessVisible.ts +63 -0
  134. package/src/deck-hooks/index.ts +1 -0
  135. package/src/deck-hooks/useParams.ts +15 -0
  136. package/src/globals/SteamClient.ts +321 -0
  137. package/src/globals/index.ts +2 -0
  138. package/src/globals/stores.ts +50 -0
  139. package/src/index.ts +20 -0
  140. package/src/logger.ts +77 -0
  141. package/src/modules/Router.ts +174 -0
  142. package/src/modules/index.ts +1 -0
  143. package/src/utils/index.ts +43 -0
  144. package/src/utils/patcher.ts +160 -0
  145. package/src/utils/react.ts +141 -0
  146. package/src/utils/static-classes.ts +1104 -0
  147. package/src/webpack.ts +136 -0
@@ -0,0 +1,30 @@
1
+ import { ReactNode, FC } from 'react';
2
+
3
+ import { Export, findModuleExport } from '../webpack';
4
+ import { createPropListRegex } from '../utils';
5
+
6
+ export interface SidebarNavigationPage {
7
+ title: ReactNode;
8
+ content: ReactNode;
9
+ icon?: ReactNode;
10
+ visible?: boolean;
11
+ hideTitle?: boolean;
12
+ identifier?: string;
13
+ route?: string;
14
+ link?: string;
15
+ padding?: 'none' | 'compact';
16
+ }
17
+
18
+ export interface SidebarNavigationProps {
19
+ title?: string;
20
+ pages: (SidebarNavigationPage | 'separator')[];
21
+ showTitle?: boolean;
22
+ disableRouteReporting?: boolean;
23
+ page?: string;
24
+ onPageRequested?: (page: string) => void;
25
+ }
26
+
27
+ const sidebarNavigationRegex = createPropListRegex(["pages", "fnSetNavigateToPage", "disableRouteReporting"]);
28
+ export const SidebarNavigation = findModuleExport((e: Export) =>
29
+ e?.toString && sidebarNavigationRegex.test(e.toString()),
30
+ ) as FC<SidebarNavigationProps>;
@@ -0,0 +1,33 @@
1
+ import { FC } from 'react';
2
+
3
+ import { CommonUIModule } from '../webpack';
4
+ import { ItemProps } from './Item';
5
+
6
+ export interface NotchLabel {
7
+ notchIndex: number;
8
+ label: string;
9
+ value?: number;
10
+ }
11
+
12
+ export interface SliderFieldProps extends ItemProps {
13
+ value: number;
14
+ min?: number;
15
+ max?: number;
16
+ step?: number;
17
+ notchCount?: number;
18
+ notchLabels?: NotchLabel[];
19
+ notchTicksVisible?: boolean;
20
+ showValue?: boolean;
21
+ resetValue?: number;
22
+ disabled?: boolean;
23
+ editableValue?: boolean;
24
+ validValues?: 'steps' | 'range' | ((value: number) => boolean);
25
+ valueSuffix?: string;
26
+ minimumDpadGranularity?: number;
27
+ onChange?(value: number): void;
28
+ className?: string;
29
+ }
30
+
31
+ export const SliderField = Object.values(CommonUIModule).find((mod: any) =>
32
+ mod?.toString()?.includes('SliderField,fallback'),
33
+ ) as FC<SliderFieldProps>;
@@ -0,0 +1,8 @@
1
+ import { FC, SVGAttributes } from 'react';
2
+
3
+ import { IconsModule } from '../webpack';
4
+
5
+ // TODO type this and other icons?
6
+ export const Spinner = Object.values(IconsModule)?.find(
7
+ (mod: any) => mod?.toString && /Spinner\)}\)?,.\.createElement\(\"path\",{d:\"M18 /.test(mod.toString()),
8
+ ) as FC<SVGAttributes<SVGElement>>;
@@ -0,0 +1,7 @@
1
+ import { FC, SVGAttributes } from 'react';
2
+
3
+ import { Export, findModuleExport } from '../webpack';
4
+
5
+ export const SteamSpinner = findModuleExport(
6
+ (e: Export) => e?.toString?.()?.includes('Steam Spinner') && e?.toString?.()?.includes('src'),
7
+ ) as FC<SVGAttributes<SVGElement>>;
@@ -0,0 +1,127 @@
1
+ import { FC, ReactNode, createElement, useEffect, useState } from 'react';
2
+
3
+ import { fakeRenderComponent, findInReactTree, sleep } from '../utils';
4
+ import { Export, findModuleByExport } from '../webpack';
5
+ import { FooterLegendProps } from './FooterLegend';
6
+ import { SteamSpinner } from './SteamSpinner';
7
+
8
+ /**
9
+ * Individual tab objects for the Tabs component
10
+ *
11
+ * `id` ID of this tab, can be used with activeTab to auto-focus a given tab
12
+ * `title` Title shown in the header bar
13
+ * `renderTabAddon` Return a {@link ReactNode} to render it next to the tab title, i.e. the counts for each tab on the Media page
14
+ * `content` Content of the tab
15
+ * `footer` Sets up button handlers and labels
16
+ */
17
+ export interface Tab {
18
+ id: string;
19
+ title: string;
20
+ renderTabAddon?: () => ReactNode;
21
+ content: ReactNode;
22
+ footer?: FooterLegendProps;
23
+ }
24
+
25
+ /**
26
+ * Props for the {@link Tabs}
27
+ *
28
+ * `tabs` array of {@link Tab}
29
+ * `activeTab` tab currently active, needs to be one of the tabs {@link Tab.id}, must be set using a `useState` in the `onShowTab` handler
30
+ * `onShowTab` Called when the active tab should change, needs to set `activeTab`. See example.
31
+ * `autoFocusContents` Whether to automatically focus the tab contents or not.
32
+ * `footer` Sets up button handlers and labels
33
+ *
34
+ * @example
35
+ * const Component: FC = () => {
36
+ * const [currentTab, setCurrentTab] = useState<string>("Tab1");
37
+ *
38
+ * return (
39
+ * <Tabs
40
+ * title="Theme Manager"
41
+ * activeTab={currentTabRoute}
42
+ * onShowTab={(tabID: string) => {
43
+ * setCurrentTabRoute(tabID);
44
+ * }}
45
+ * tabs={[
46
+ * {
47
+ * title: "Tab 1",
48
+ * content: <Tab1Component />,
49
+ * id: "Tab1",
50
+ * },
51
+ * {
52
+ * title: "Tab 2",
53
+ * content: <Tab2Component />,
54
+ * id: "Tab2",
55
+ * },
56
+ * ]}
57
+ * />
58
+ * );
59
+ * };
60
+ */
61
+ export interface TabsProps {
62
+ tabs: Tab[];
63
+ activeTab: string;
64
+ onShowTab: (tab: string) => void;
65
+ autoFocusContents?: boolean;
66
+ }
67
+
68
+ let tabsComponent: any;
69
+
70
+ const getTabs = async () => {
71
+ if (tabsComponent) return tabsComponent;
72
+ // @ts-ignore
73
+ while (!window?.DeckyPluginLoader?.routerHook?.routes) {
74
+ console.debug('[DFL:Tabs]: Waiting for Decky router...');
75
+ await sleep(500);
76
+ }
77
+ return (tabsComponent = fakeRenderComponent(
78
+ () => {
79
+ return findInReactTree(
80
+ findInReactTree(
81
+ // @ts-ignore
82
+ window.DeckyPluginLoader.routerHook.routes
83
+ .find((x: any) => x.props.path == '/library/app/:appid/achievements')
84
+ .props.children.type(),
85
+ (x) => x?.props?.scrollTabsTop,
86
+ ).type({ appid: 1 }),
87
+ (x) => x?.props?.tabs,
88
+ ).type;
89
+ },
90
+ {
91
+ useRef: () => ({ current: { reaction: { track: () => {} } } }),
92
+ useContext: () => ({ match: { params: { appid: 1 } } }),
93
+ useMemo: () => ({ data: {} }),
94
+ },
95
+ ));
96
+ };
97
+
98
+ let oldTabs: any;
99
+
100
+ try {
101
+ const oldTabsModule = findModuleByExport((e: Export) => e.Unbleed);
102
+ if (oldTabsModule)
103
+ oldTabs = Object.values(oldTabsModule).find((x: any) => x?.type?.toString()?.includes('((function(){'));
104
+ } catch (e) {
105
+ console.error('Error finding oldTabs:', e);
106
+ }
107
+
108
+ /**
109
+ * Tabs component as used in the library and media tabs. See {@link TabsProps}.
110
+ * Unlike other components in `decky-frontend-lib`, this requires Decky Loader to be running.
111
+ */
112
+ export const Tabs = (oldTabs ||
113
+ ((props: TabsProps) => {
114
+ const found = tabsComponent;
115
+ const [tc, setTC] = useState<FC<TabsProps>>(found);
116
+ useEffect(() => {
117
+ if (found) return;
118
+ (async () => {
119
+ console.debug('[DFL:Tabs]: Finding component...');
120
+ const t = await getTabs();
121
+ console.debug('[DFL:Tabs]: Found!');
122
+ setTC(t);
123
+ })();
124
+ }, []);
125
+ console.log('tc', tc);
126
+ return tc ? createElement(tc, props) : <SteamSpinner />;
127
+ })) as FC<TabsProps>;
@@ -0,0 +1,28 @@
1
+ import { ChangeEventHandler, HTMLAttributes, ReactNode, FC } from 'react';
2
+
3
+ import { CommonUIModule, Module } from '../webpack';
4
+
5
+ export interface TextFieldProps extends HTMLAttributes<HTMLInputElement> {
6
+ label?: ReactNode;
7
+ requiredLabel?: ReactNode;
8
+ description?: ReactNode;
9
+ disabled?: boolean;
10
+ bShowCopyAction?: boolean;
11
+ bShowClearAction?: boolean;
12
+ bAlwaysShowClearAction?: boolean;
13
+ bIsPassword?: boolean;
14
+ rangeMin?: number;
15
+ rangeMax?: number;
16
+ mustBeNumeric?: boolean;
17
+ mustBeURL?: boolean;
18
+ mustBeEmail?: boolean;
19
+ focusOnMount?: boolean;
20
+ tooltip?: string;
21
+ inlineControls?: ReactNode;
22
+ onChange?: ChangeEventHandler<HTMLInputElement>;
23
+ value?: string;
24
+ }
25
+
26
+ export const TextField = Object.values(CommonUIModule).find(
27
+ (mod: Module) => mod?.validateUrl && mod?.validateEmail,
28
+ ) as FC<TextFieldProps>;
@@ -0,0 +1,14 @@
1
+ import { FC } from 'react';
2
+
3
+ import { CommonUIModule } from '../webpack';
4
+
5
+ export interface ToggleProps {
6
+ value: boolean;
7
+ disabled?: boolean;
8
+ onChange?(checked: boolean): void;
9
+ navRef?: any; // TODO figure out what this is
10
+ }
11
+
12
+ export const Toggle = Object.values(CommonUIModule).find((mod: any) =>
13
+ mod?.render?.toString()?.includes('.ToggleOff)'),
14
+ ) as FC<ToggleProps>;
@@ -0,0 +1,15 @@
1
+ import { FC } from 'react';
2
+
3
+ import { CommonUIModule } from '../webpack';
4
+ import { ItemProps } from './Item';
5
+
6
+ export interface ToggleFieldProps extends ItemProps {
7
+ highlightOnFocus?: boolean;
8
+ checked: boolean;
9
+ disabled?: boolean;
10
+ onChange?(checked: boolean): void;
11
+ }
12
+
13
+ export const ToggleField = Object.values(CommonUIModule).find((mod: any) =>
14
+ mod?.render?.toString()?.includes('ToggleField,fallback'),
15
+ ) as FC<ToggleFieldProps>;
@@ -0,0 +1,25 @@
1
+ export * from './Button';
2
+ export * from './ButtonItem';
3
+ export * from './Carousel';
4
+ export * from './ControlsList';
5
+ export * from './Dialog';
6
+ export * from './DialogCheckbox';
7
+ export * from './Dropdown';
8
+ export * from './Field';
9
+ export * from './Focusable';
10
+ export * from './FocusRing';
11
+ export * from './FooterLegend';
12
+ export * from './Marquee';
13
+ export * from './Menu';
14
+ export * from './Modal';
15
+ export * from './Panel';
16
+ export * from './ProgressBar';
17
+ export * from './SidebarNavigation';
18
+ export * from './SliderField';
19
+ export * from './Spinner';
20
+ export * from './SteamSpinner';
21
+ export * from './Tabs';
22
+ export * from './TextField';
23
+ export * from './Toggle';
24
+ export * from './ToggleField';
25
+ export * from './Scroll';
@@ -0,0 +1,132 @@
1
+ import { CSSProperties, FC, useState } from 'react';
2
+
3
+ import { ConfirmModal, SliderField } from '../components';
4
+ import { gamepadSliderClasses } from '../utils/static-classes';
5
+
6
+ interface ColorPickerModalProps {
7
+ closeModal: () => void;
8
+ onConfirm?(HSLString: string): any;
9
+ title?: string;
10
+ defaultH?: number;
11
+ defaultS?: number;
12
+ defaultL?: number;
13
+ defaultA?: number;
14
+ }
15
+
16
+ export const ColorPickerModal: FC<ColorPickerModalProps> = ({
17
+ closeModal,
18
+ onConfirm = () => {},
19
+ title = 'Color Picker',
20
+ defaultH = 0,
21
+ defaultS = 100,
22
+ defaultL = 50,
23
+ defaultA = 1,
24
+ }) => {
25
+ const [H, setH] = useState<number>(defaultH);
26
+ const [S, setS] = useState<number>(defaultS);
27
+ const [L, setL] = useState<number>(defaultL);
28
+ const [A, setA] = useState<number>(defaultA);
29
+
30
+ const colorPickerCSSVars = {
31
+ '--decky-color-picker-hvalue': `${H}`,
32
+ '--decky-color-picker-svalue': `${S}%`,
33
+ '--decky-color-picker-lvalue': `${L}%`,
34
+ '--decky-color-picker-avalue': `${A}`,
35
+ } as CSSProperties;
36
+
37
+ return (
38
+ <ConfirmModal
39
+ bAllowFullSize
40
+ onCancel={closeModal}
41
+ onOK={() => {
42
+ onConfirm(`hsla(${H}, ${S}%, ${L}%, ${A})`);
43
+ closeModal();
44
+ }}
45
+ >
46
+ <style>
47
+ {`
48
+ /* This removes the cyan track color that is behind the slider head */
49
+ .ColorPicker_Container .${gamepadSliderClasses.SliderTrack} {
50
+ --left-track-color: #0000;
51
+ /* This is for compatibility with the "Colored Toggles" CSSLoader Theme*/
52
+ --colored-toggles-main-color: #0000;
53
+ }
54
+
55
+ .ColorPicker_HSlider .${gamepadSliderClasses.SliderTrack} {
56
+ background: linear-gradient(
57
+ 270deg,
58
+ hsla(360, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
59
+ hsla(270, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
60
+ hsla(180, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
61
+ hsla(90, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
62
+ hsla(0, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue))
63
+ );
64
+ }
65
+
66
+ .ColorPicker_SSlider .${gamepadSliderClasses.SliderTrack} {
67
+ background: linear-gradient(
68
+ 90deg,
69
+ hsla(var(--decky-color-picker-hvalue), 0%, var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
70
+ hsla(var(--decky-color-picker-hvalue), 100%, var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue))
71
+ );
72
+ }
73
+
74
+ .ColorPicker_LSlider .${gamepadSliderClasses.SliderTrack} {
75
+ background: linear-gradient(
76
+ 90deg,
77
+ hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), 0%, var(--decky-color-picker-avalue)),
78
+ hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), 50%, var(--decky-color-picker-avalue)),
79
+ hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), 100%, var(--decky-color-picker-avalue))
80
+ );
81
+ }
82
+
83
+ .ColorPicker_ASlider .${gamepadSliderClasses.SliderTrack} {
84
+ background: linear-gradient(
85
+ 90deg,
86
+ hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), 0),
87
+ hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), 1)
88
+ );
89
+ }
90
+ `}
91
+ </style>
92
+ <div
93
+ className="ColorPicker_ColorDisplayContainer"
94
+ style={{
95
+ display: 'flex',
96
+ justifyContent: 'space-between',
97
+ alignItems: 'center',
98
+ marginBottom: '1em',
99
+ // theres a large header by default on the modal, so this just pushes it up into that unused space
100
+ marginTop: '-2.5em',
101
+ }}
102
+ >
103
+ <div>
104
+ <span style={{ fontSize: '1.5em' }}>
105
+ <b>{title}</b>
106
+ </span>
107
+ </div>
108
+ <div
109
+ style={{
110
+ backgroundColor: `hsla(${H}, ${S}%, ${L}%, ${A})`,
111
+ width: '40px',
112
+ height: '40px',
113
+ }}
114
+ ></div>
115
+ </div>
116
+ <div className="ColorPicker_Container" style={colorPickerCSSVars}>
117
+ <div className="ColorPicker_HSlider">
118
+ <SliderField showValue editableValue label="Hue" value={H} min={0} max={360} onChange={setH} />
119
+ </div>
120
+ <div className="ColorPicker_SSlider">
121
+ <SliderField showValue editableValue label="Saturation" value={S} min={0} max={100} onChange={setS} />
122
+ </div>
123
+ <div className="ColorPicker_LSlider">
124
+ <SliderField showValue editableValue label="Lightness" value={L} min={0} max={100} onChange={setL} />
125
+ </div>
126
+ <div className="ColorPicker_ASlider">
127
+ <SliderField showValue editableValue label="Alpha" value={A} step={0.1} min={0} max={1} onChange={setA} />
128
+ </div>
129
+ </div>
130
+ </ConfirmModal>
131
+ );
132
+ };
@@ -0,0 +1,183 @@
1
+ import { Fragment, JSXElementConstructor, ReactElement, ReactNode, useEffect, useState } from 'react';
2
+
3
+ import { Field, FieldProps, Focusable, GamepadButton } from '../components';
4
+
5
+ /**
6
+ * A ReorderableList entry of type <T>.
7
+ * @param label The name of this entry in the list.
8
+ * @param data Optional data to connect to this entry.
9
+ * @param position The position of this entry in the list.
10
+ */
11
+ export type ReorderableEntry<T> = {
12
+ label: ReactNode;
13
+ data?: T;
14
+ position: number;
15
+ };
16
+
17
+ /**
18
+ * Properties for a ReorderableList component of type <T>.
19
+ *
20
+ * @param animate If the list should animate. @default true
21
+ */
22
+ export type ReorderableListProps<T> = {
23
+ entries: ReorderableEntry<T>[];
24
+ onSave: (entries: ReorderableEntry<T>[]) => void;
25
+ interactables?: JSXElementConstructor<{ entry: ReorderableEntry<T> }>;
26
+ fieldProps?: FieldProps;
27
+ animate?: boolean;
28
+ };
29
+
30
+ /**
31
+ * A component for creating reorderable lists.
32
+ *
33
+ * See an example implementation {@linkplain https://github.com/Tormak9970/Component-Testing-Plugin/blob/main/src/testing-window/ReorderableListTest.tsx here}.
34
+ */
35
+ export function ReorderableList<T>(props: ReorderableListProps<T>) {
36
+ if (props.animate === undefined) props.animate = true;
37
+ const [entryList, setEntryList] = useState<ReorderableEntry<T>[]>(
38
+ props.entries.sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position),
39
+ );
40
+ const [reorderEnabled, setReorderEnabled] = useState<boolean>(false);
41
+
42
+ useEffect(() => {
43
+ setEntryList(props.entries.sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position));
44
+ }, [props.entries]);
45
+
46
+ function toggleReorderEnabled(): void {
47
+ let newReorderValue = !reorderEnabled;
48
+ setReorderEnabled(newReorderValue);
49
+
50
+ if (!newReorderValue) {
51
+ props.onSave(entryList);
52
+ }
53
+ }
54
+
55
+ function saveOnBackout(e: Event) {
56
+ const event = e as CustomEvent;
57
+ if (event.detail.button == GamepadButton.CANCEL && reorderEnabled) {
58
+ setReorderEnabled(!reorderEnabled);
59
+ props.onSave(entryList);
60
+ }
61
+ }
62
+
63
+ return (
64
+ <Fragment>
65
+ <div
66
+ style={{
67
+ width: 'inherit',
68
+ height: 'inherit',
69
+ flex: '1 1 1px',
70
+ scrollPadding: '48px 0px',
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ justifyContent: 'flex-start',
74
+ alignContent: 'stretch',
75
+ }}
76
+ >
77
+ <Focusable
78
+ onSecondaryButton={toggleReorderEnabled}
79
+ onSecondaryActionDescription={reorderEnabled ? 'Save Order' : 'Reorder'}
80
+ onClick={toggleReorderEnabled}
81
+ onButtonDown={saveOnBackout}
82
+ >
83
+ {entryList.map((entry: ReorderableEntry<T>) => (
84
+ <ReorderableItem
85
+ animate={props.animate!}
86
+ listData={entryList}
87
+ entryData={entry}
88
+ reorderEntryFunc={setEntryList}
89
+ reorderEnabled={reorderEnabled}
90
+ fieldProps={props.fieldProps}
91
+ >
92
+ {props.interactables ? <props.interactables entry={entry} /> : null}
93
+ </ReorderableItem>
94
+ ))}
95
+ </Focusable>
96
+ </div>
97
+ </Fragment>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Properties for a ReorderableItem component of type <T>
103
+ */
104
+ export type ReorderableListEntryProps<T> = {
105
+ fieldProps?: FieldProps;
106
+ listData: ReorderableEntry<T>[];
107
+ entryData: ReorderableEntry<T>;
108
+ reorderEntryFunc: CallableFunction;
109
+ reorderEnabled: boolean;
110
+ animate: boolean;
111
+ children: ReactElement | null;
112
+ };
113
+
114
+ function ReorderableItem<T>(props: ReorderableListEntryProps<T>) {
115
+ const [isSelected, _setIsSelected] = useState<boolean>(false);
116
+ const [isSelectedLastFrame, setIsSelectedLastFrame] = useState<boolean>(false);
117
+ const listEntries = props.listData;
118
+
119
+ function onReorder(e: Event): void {
120
+ if (!props.reorderEnabled) return;
121
+
122
+ const event = e as CustomEvent;
123
+ const currentIdx = listEntries.findIndex((entryData: ReorderableEntry<T>) => entryData === props.entryData);
124
+ const currentIdxValue = listEntries[currentIdx];
125
+ if (currentIdx < 0) return;
126
+
127
+ let targetPosition: number = -1;
128
+ if (event.detail.button == GamepadButton.DIR_DOWN) {
129
+ targetPosition = currentIdxValue.position + 1;
130
+ } else if (event.detail.button == GamepadButton.DIR_UP) {
131
+ targetPosition = currentIdxValue.position - 1;
132
+ }
133
+
134
+ if (targetPosition >= listEntries.length || targetPosition < 0) return;
135
+
136
+ let otherToUpdate = listEntries.find((entryData: ReorderableEntry<T>) => entryData.position === targetPosition);
137
+ if (!otherToUpdate) return;
138
+
139
+ let currentPosition = currentIdxValue.position;
140
+
141
+ currentIdxValue.position = otherToUpdate.position;
142
+ otherToUpdate.position = currentPosition;
143
+
144
+ props.reorderEntryFunc(
145
+ [...listEntries].sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position),
146
+ );
147
+ }
148
+
149
+ async function setIsSelected(val: boolean) {
150
+ _setIsSelected(val);
151
+ // Wait 3 frames, then set. I have no idea why, but if you dont wait long enough it doesn't work.
152
+ for (let i = 0; i < 3; i++) await new Promise((res) => requestAnimationFrame(res));
153
+ setIsSelectedLastFrame(val);
154
+ }
155
+
156
+ return (
157
+ <div
158
+ style={
159
+ props.animate
160
+ ? {
161
+ transition:
162
+ isSelected || isSelectedLastFrame
163
+ ? ''
164
+ : '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
165
+ transform: !props.reorderEnabled || isSelected ? 'scale(1)' : 'scale(0.9)',
166
+ opacity: !props.reorderEnabled || isSelected ? 1 : 0.7,
167
+ }
168
+ : {}
169
+ }
170
+ >
171
+ <Field
172
+ label={props.entryData.label}
173
+ {...props.fieldProps}
174
+ focusable={!props.children}
175
+ onButtonDown={onReorder}
176
+ onGamepadBlur={() => setIsSelected(false)}
177
+ onGamepadFocus={() => setIsSelected(true)}
178
+ >
179
+ <Focusable style={{ display: 'flex', width: '100%', position: 'relative' }}>{props.children}</Focusable>
180
+ </Field>
181
+ </div>
182
+ );
183
+ }
@@ -0,0 +1,44 @@
1
+ import { useEffect } from 'react';
2
+ import { FC, ImgHTMLAttributes, useState } from 'react';
3
+
4
+ import { Spinner } from '../components';
5
+
6
+ interface SuspensefulImageProps extends ImgHTMLAttributes<HTMLImageElement> {
7
+ suspenseWidth?: string | number;
8
+ suspenseHeight?: string | number;
9
+ }
10
+
11
+ export const SuspensefulImage: FC<SuspensefulImageProps> = (props) => {
12
+ const [loading, setLoading] = useState(true);
13
+ const [error, setError] = useState(false);
14
+
15
+ useEffect(() => {
16
+ setLoading(true);
17
+ setError(false);
18
+ const img = new Image();
19
+ img.src = props.src || '';
20
+ img.addEventListener('load', () => {
21
+ setLoading(false);
22
+ });
23
+ img.addEventListener('error', () => {
24
+ setError(true);
25
+ });
26
+ }, [props.src]);
27
+
28
+ return loading ? (
29
+ <div
30
+ style={{
31
+ width: props.suspenseWidth || props.style?.width,
32
+ height: props.suspenseHeight || props.style?.height,
33
+ background: 'rgba(255, 255, 255, 0.2)',
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ }}
38
+ >
39
+ {error ? 'Missing image' : <Spinner style={{ height: '48px' }} />}
40
+ </div>
41
+ ) : (
42
+ <img {...props} />
43
+ );
44
+ };
@@ -0,0 +1,3 @@
1
+ export * from './SuspensefulImage';
2
+ export * from './ColorPickerModal';
3
+ export * from './ReorderableList';
@@ -0,0 +1 @@
1
+ export * from './useQuickAccessVisible';