@spicetify/kit 0.1.0 → 0.3.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.
- package/dist/check.js +9 -5
- package/dist/create.js +10 -11
- package/dist/from-theme.js +2 -1
- package/dist/stdlib-boundary.js +259 -0
- package/dist/version.js +8 -0
- package/package.json +52 -48
- package/vendor/shims/platform.d.ts +21201 -0
- package/vendor/shims/spicetify.d.ts +23 -23
- package/vendor/stdlib/lib/dialog-lifecycle.ts +53 -0
- package/vendor/stdlib/lib/floating-position.ts +57 -0
- package/vendor/stdlib/lib/floating.tsx +298 -0
- package/vendor/stdlib/lib/modal.tsx +20 -113
- package/vendor/stdlib/lib/popover.ts +26 -19
- package/vendor/stdlib/lib/primitives-classes.ts +12 -0
- package/vendor/stdlib/lib/primitives-vanilla.ts +27 -3
- package/vendor/stdlib/lib/primitives.tsx +176 -26
- package/vendor/stdlib/metadata.json +1 -1
- package/vendor/stdlib/mod.ts +8 -5
- package/vendor/stdlib/src/client.ts +65 -2
- package/vendor/stdlib/src/expose/Platform.ts +5 -2
- package/vendor/stdlib/src/registers/index.ts +40 -3
- package/vendor/stdlib/src/registers/panel-logic.ts +371 -0
- package/vendor/stdlib/src/registers/panel.ts +63 -151
- package/vendor/stdlib/src/registers/playbarButton.tsx +1 -1
- package/vendor/stdlib/src/registers/route.ts +2 -1
- package/vendor/stdlib/src/registers/settingsRow.tsx +4 -4
- package/vendor/stdlib/src/registers/settingsSection.ts +61 -41
- package/vendor/stdlib/src/registers/topbarLeftButton.tsx +1 -1
- package/vendor/stdlib/src/registers/topbarRightButton.tsx +3 -5
- package/vendor/stdlib/src/settings/corsProxy.tsx +102 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// modules can use it without touching React.
|
|
11
11
|
|
|
12
12
|
import { React, ReactDOM } from "../src/expose/React.ts";
|
|
13
|
+
import { calculateFloatingPosition } from "./floating-position.ts";
|
|
13
14
|
|
|
14
15
|
export interface PopoverHandle {
|
|
15
16
|
close: () => void;
|
|
@@ -26,40 +27,40 @@ export interface PopoverOptions {
|
|
|
26
27
|
export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOptions): PopoverHandle {
|
|
27
28
|
const host = document.createElement("div");
|
|
28
29
|
host.className = "spicetify-popover";
|
|
29
|
-
|
|
30
|
-
position: "fixed",
|
|
31
|
-
zIndex: "1000",
|
|
32
|
-
background: "var(--spice-card, var(--background-elevated-base, #282828))",
|
|
33
|
-
color: "var(--spice-text, var(--text-base, #fff))",
|
|
34
|
-
borderRadius: "8px",
|
|
35
|
-
boxShadow: "0 16px 24px rgba(0, 0, 0, 0.3), 0 6px 8px rgba(0, 0, 0, 0.2)",
|
|
36
|
-
maxHeight: "80vh",
|
|
37
|
-
overflow: "auto",
|
|
38
|
-
});
|
|
30
|
+
host.style.visibility = "hidden";
|
|
39
31
|
|
|
40
32
|
const place = () => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
if (!anchor.isConnected || !host.isConnected) return;
|
|
34
|
+
const position = calculateFloatingPosition(
|
|
35
|
+
anchor.getBoundingClientRect(),
|
|
36
|
+
host.getBoundingClientRect(),
|
|
37
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
38
|
+
{ align: "start", gap },
|
|
39
|
+
);
|
|
40
|
+
host.style.left = `${position.left}px`;
|
|
41
|
+
host.style.top = `${position.top}px`;
|
|
42
|
+
host.style.visibility = "visible";
|
|
43
|
+
host.dataset.placement = position.placement;
|
|
45
44
|
};
|
|
46
45
|
|
|
47
|
-
let root:
|
|
46
|
+
let root: ReturnType<typeof ReactDOM.createRoot> | undefined;
|
|
48
47
|
if (content instanceof HTMLElement) {
|
|
49
48
|
host.appendChild(content);
|
|
50
49
|
} else {
|
|
51
|
-
|
|
52
|
-
root
|
|
53
|
-
(root as any).render(React.createElement(React.Fragment, null, content as any));
|
|
50
|
+
root = ReactDOM.createRoot(host);
|
|
51
|
+
root.render(React.createElement(React.Fragment, null, content));
|
|
54
52
|
}
|
|
53
|
+
const resizeObserver = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(place);
|
|
55
54
|
|
|
56
55
|
let closed = false;
|
|
57
56
|
const close = () => {
|
|
58
57
|
if (closed) return;
|
|
59
58
|
closed = true;
|
|
60
59
|
window.removeEventListener("resize", place);
|
|
60
|
+
document.removeEventListener("scroll", place, true);
|
|
61
61
|
document.removeEventListener("mousedown", onDocDown, true);
|
|
62
62
|
document.removeEventListener("keydown", onKey, true);
|
|
63
|
+
resizeObserver?.disconnect();
|
|
63
64
|
root?.unmount();
|
|
64
65
|
host.remove();
|
|
65
66
|
onClose?.();
|
|
@@ -69,7 +70,10 @@ export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOption
|
|
|
69
70
|
if (!host.contains(target) && !anchor.contains(target)) close();
|
|
70
71
|
};
|
|
71
72
|
const onKey = (e: KeyboardEvent) => {
|
|
72
|
-
if (e.key
|
|
73
|
+
if (e.key !== "Escape") return;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
close();
|
|
73
77
|
};
|
|
74
78
|
|
|
75
79
|
document.body.appendChild(host);
|
|
@@ -77,8 +81,11 @@ export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOption
|
|
|
77
81
|
// A second pass once layout has produced the real width, for clamping.
|
|
78
82
|
requestAnimationFrame(place);
|
|
79
83
|
window.addEventListener("resize", place);
|
|
84
|
+
document.addEventListener("scroll", place, true);
|
|
80
85
|
document.addEventListener("mousedown", onDocDown, true);
|
|
81
86
|
document.addEventListener("keydown", onKey, true);
|
|
87
|
+
resizeObserver?.observe(anchor);
|
|
88
|
+
resizeObserver?.observe(host);
|
|
82
89
|
|
|
83
90
|
return { close, host };
|
|
84
91
|
}
|
|
@@ -33,6 +33,7 @@ export const SEARCHBAR_CLASS = "spicetify-searchbar";
|
|
|
33
33
|
export const CARD_CLASS = "spicetify-card";
|
|
34
34
|
export const SCRIM_CLASS = "spicetify-scrim";
|
|
35
35
|
export const DIALOG_CLASS = "spicetify-dialog";
|
|
36
|
+
export const DIALOG_LARGE_CLASS = "spicetify-dialog--large";
|
|
36
37
|
export const DIALOG_HEADER_CLASS = "spicetify-dialog-header";
|
|
37
38
|
export const DIALOG_BODY_CLASS = "spicetify-dialog-body";
|
|
38
39
|
|
|
@@ -43,6 +44,17 @@ export const SETTINGS_SECTION_CLASS = "x-settings-section";
|
|
|
43
44
|
export const SETTINGS_ROW_CLASS = "x-settings-row";
|
|
44
45
|
export const SETTINGS_ROW_LABEL_CLASS = "x-settings-firstColumn";
|
|
45
46
|
export const SETTINGS_ROW_CONTROL_CLASS = "x-settings-secondColumn";
|
|
47
|
+
export const SETTINGS_ACTION_GROUP_CLASS = "spicetify-settings-actions";
|
|
48
|
+
export const SETTINGS_LABEL_COPY_CLASS = "spicetify-settings-label-copy";
|
|
49
|
+
export const SETTINGS_HEADER_CONTAINER_CLASS = "x-settings-headerContainer";
|
|
50
|
+
export const SETTINGS_HEADER_CLASS =
|
|
51
|
+
"encore-text encore-text-title-medium encore-internal-color-text-base x-settings-header";
|
|
52
|
+
export const SETTINGS_SECTION_HEADING_CLASS =
|
|
53
|
+
"encore-text encore-text-body-medium-bold encore-internal-color-text-base spicetify-settings-section-heading";
|
|
54
|
+
export const SETTINGS_SECTION_SUBHEADING_CLASS =
|
|
55
|
+
"encore-text encore-text-body-medium-bold encore-internal-color-text-base spicetify-settings-section-subheading";
|
|
56
|
+
export const SETTINGS_ROW_TEXT_CLASS = "encore-text encore-text-body-small encore-internal-color-text-subdued";
|
|
57
|
+
export const SETTINGS_HELP_TEXT_CLASS = "encore-text encore-text-marginal encore-internal-color-text-subdued";
|
|
46
58
|
export const TOGGLE_CLASSES = {
|
|
47
59
|
wrapper: "x-toggle-wrapper",
|
|
48
60
|
input: "x-toggle-input",
|
|
@@ -27,11 +27,13 @@ import {
|
|
|
27
27
|
DIALOG_BODY_CLASS,
|
|
28
28
|
DIALOG_CLASS,
|
|
29
29
|
DIALOG_HEADER_CLASS,
|
|
30
|
+
DIALOG_LARGE_CLASS,
|
|
30
31
|
ICON_BUTTON_CLASS,
|
|
31
32
|
SCRIM_CLASS,
|
|
32
33
|
SEARCHBAR_CLASS,
|
|
33
34
|
SELECT_CLASS,
|
|
34
35
|
} from "./primitives-classes.ts";
|
|
36
|
+
import { activateDialog } from "./dialog-lifecycle.ts";
|
|
35
37
|
|
|
36
38
|
export type { BadgeTone, ButtonVariant } from "./primitives-classes.ts";
|
|
37
39
|
|
|
@@ -246,22 +248,43 @@ export interface DialogHandle {
|
|
|
246
248
|
// backdrop, close button, and programmatic close() all run one teardown,
|
|
247
249
|
// so onClose fires exactly once on any dismissal path (consumers use it
|
|
248
250
|
// to drop their own bookkeeping, e.g. a dispose registry).
|
|
249
|
-
|
|
251
|
+
let nextDialogTitleId = 0;
|
|
252
|
+
|
|
253
|
+
export function openDialog(props: {
|
|
254
|
+
title: string;
|
|
255
|
+
children: Child;
|
|
256
|
+
size?: "normal" | "large";
|
|
257
|
+
onClose?: () => void;
|
|
258
|
+
}): DialogHandle {
|
|
250
259
|
const body = h("div", { className: DIALOG_BODY_CLASS }, props.children);
|
|
251
260
|
let closed = false;
|
|
261
|
+
let deactivate = () => {};
|
|
252
262
|
const close = () => {
|
|
253
263
|
if (closed) return;
|
|
254
264
|
closed = true;
|
|
265
|
+
deactivate();
|
|
255
266
|
scrim.remove();
|
|
256
267
|
props.onClose?.();
|
|
257
268
|
};
|
|
269
|
+
const titleId = `spicetify-dialog-title-${++nextDialogTitleId}`;
|
|
258
270
|
const header = h(
|
|
259
271
|
"div",
|
|
260
272
|
{ className: DIALOG_HEADER_CLASS },
|
|
261
|
-
h("h2", { textContent: props.title }),
|
|
273
|
+
h("h2", { id: titleId, textContent: props.title }),
|
|
262
274
|
IconButton({ glyph: "×", ariaLabel: "Close", onClick: close }),
|
|
263
275
|
);
|
|
264
|
-
const dialog = h(
|
|
276
|
+
const dialog = h(
|
|
277
|
+
"div",
|
|
278
|
+
{
|
|
279
|
+
className: props.size === "large" ? `${DIALOG_CLASS} ${DIALOG_LARGE_CLASS}` : DIALOG_CLASS,
|
|
280
|
+
tabIndex: -1,
|
|
281
|
+
role: "dialog",
|
|
282
|
+
"aria-modal": "true",
|
|
283
|
+
"aria-labelledby": titleId,
|
|
284
|
+
},
|
|
285
|
+
header,
|
|
286
|
+
body,
|
|
287
|
+
);
|
|
265
288
|
const scrim = h(
|
|
266
289
|
"div",
|
|
267
290
|
{
|
|
@@ -273,5 +296,6 @@ export function openDialog(props: { title: string; children: Child; onClose?: ()
|
|
|
273
296
|
dialog,
|
|
274
297
|
);
|
|
275
298
|
document.body.append(scrim);
|
|
299
|
+
deactivate = activateDialog(dialog, close);
|
|
276
300
|
return { body, close };
|
|
277
301
|
}
|
|
@@ -25,7 +25,12 @@ import {
|
|
|
25
25
|
SETTINGS_ROW_CLASS,
|
|
26
26
|
SETTINGS_ROW_CONTROL_CLASS,
|
|
27
27
|
SETTINGS_ROW_LABEL_CLASS,
|
|
28
|
+
SETTINGS_ROW_TEXT_CLASS,
|
|
29
|
+
SETTINGS_ACTION_GROUP_CLASS,
|
|
30
|
+
SETTINGS_HELP_TEXT_CLASS,
|
|
31
|
+
SETTINGS_LABEL_COPY_CLASS,
|
|
28
32
|
SETTINGS_SECTION_CLASS,
|
|
33
|
+
SETTINGS_SECTION_HEADING_CLASS,
|
|
29
34
|
badgeClass,
|
|
30
35
|
buttonClass,
|
|
31
36
|
type BadgeTone,
|
|
@@ -34,6 +39,7 @@ import {
|
|
|
34
39
|
chipClass,
|
|
35
40
|
DIALOG_BODY_CLASS,
|
|
36
41
|
DIALOG_CLASS,
|
|
42
|
+
DIALOG_LARGE_CLASS,
|
|
37
43
|
DIALOG_HEADER_CLASS,
|
|
38
44
|
ICON_BUTTON_CLASS,
|
|
39
45
|
MENU_ITEM_CLASS,
|
|
@@ -41,8 +47,11 @@ import {
|
|
|
41
47
|
SEARCHBAR_CLASS,
|
|
42
48
|
SELECT_CLASS,
|
|
43
49
|
} from "./primitives-classes.ts";
|
|
50
|
+
import { activateDialog } from "./dialog-lifecycle.ts";
|
|
51
|
+
import { Tooltip } from "./floating.tsx";
|
|
44
52
|
|
|
45
53
|
export type { BadgeTone, ButtonVariant } from "./primitives-classes.ts";
|
|
54
|
+
export { Popover, PopoverMenu, PopoverMenuItem, Tooltip } from "./floating.tsx";
|
|
46
55
|
|
|
47
56
|
// ---------- buttons ----------
|
|
48
57
|
|
|
@@ -75,15 +84,40 @@ export const IconButton: React.FC<{
|
|
|
75
84
|
</button>
|
|
76
85
|
);
|
|
77
86
|
|
|
87
|
+
// One shared ordering affordance for provider lists and other short ordered
|
|
88
|
+
// settings. Keeping the arrows here prevents each module from inventing a
|
|
89
|
+
// different size, icon, disabled state, or accessible label.
|
|
90
|
+
export const ReorderButtons: React.FC<{
|
|
91
|
+
label: string;
|
|
92
|
+
disableUp: boolean;
|
|
93
|
+
disableDown: boolean;
|
|
94
|
+
onMove: (direction: -1 | 1) => void;
|
|
95
|
+
}> = (props) => (
|
|
96
|
+
<span className={SETTINGS_ACTION_GROUP_CLASS}>
|
|
97
|
+
<IconButton ariaLabel={`Move ${props.label} up`} disabled={props.disableUp} onClick={() => props.onMove(-1)}>
|
|
98
|
+
↑
|
|
99
|
+
</IconButton>
|
|
100
|
+
<IconButton ariaLabel={`Move ${props.label} down`} disabled={props.disableDown} onClick={() => props.onMove(1)}>
|
|
101
|
+
↓
|
|
102
|
+
</IconButton>
|
|
103
|
+
</span>
|
|
104
|
+
);
|
|
105
|
+
|
|
78
106
|
// ---------- inputs ----------
|
|
79
107
|
|
|
80
108
|
export function Select<T extends string>(props: {
|
|
81
109
|
options: ReadonlyArray<{ value: T; label: string }>;
|
|
82
110
|
value: T;
|
|
83
111
|
onChange: (value: T) => void;
|
|
112
|
+
ariaLabel?: string;
|
|
84
113
|
}): React.ReactElement {
|
|
85
114
|
return (
|
|
86
|
-
<select
|
|
115
|
+
<select
|
|
116
|
+
className={SELECT_CLASS}
|
|
117
|
+
value={props.value}
|
|
118
|
+
aria-label={props.ariaLabel}
|
|
119
|
+
onChange={(e) => props.onChange(e.target.value as T)}
|
|
120
|
+
>
|
|
87
121
|
{props.options.map((option) => (
|
|
88
122
|
<option key={option.value} value={option.value}>
|
|
89
123
|
{option.label}
|
|
@@ -97,7 +131,11 @@ export const TextInput: React.FC<{
|
|
|
97
131
|
placeholder?: string;
|
|
98
132
|
value?: string;
|
|
99
133
|
disabled?: boolean;
|
|
134
|
+
readOnly?: boolean;
|
|
100
135
|
onInput?: (value: string) => void;
|
|
136
|
+
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
|
137
|
+
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
|
138
|
+
ariaLabel?: string;
|
|
101
139
|
}> = (props) => (
|
|
102
140
|
<input
|
|
103
141
|
type="text"
|
|
@@ -105,6 +143,10 @@ export const TextInput: React.FC<{
|
|
|
105
143
|
placeholder={props.placeholder}
|
|
106
144
|
value={props.value}
|
|
107
145
|
disabled={props.disabled}
|
|
146
|
+
readOnly={props.readOnly}
|
|
147
|
+
aria-label={props.ariaLabel}
|
|
148
|
+
onFocus={props.onFocus}
|
|
149
|
+
onBlur={props.onBlur}
|
|
108
150
|
onChange={(e) => props.onInput?.(e.target.value)}
|
|
109
151
|
/>
|
|
110
152
|
);
|
|
@@ -136,13 +178,12 @@ export const Card: React.FC<{ children: React.ReactNode }> = (props) => (
|
|
|
136
178
|
|
|
137
179
|
// ---------- settings ----------
|
|
138
180
|
|
|
139
|
-
// Native-looking settings chrome for the `settingsSection` register
|
|
140
|
-
// render the client's own section
|
|
141
|
-
//
|
|
142
|
-
// does not source-transform those, so a capture there never resolves.
|
|
181
|
+
// Native-looking settings chrome for the `settingsSection` register on the
|
|
182
|
+
// standalone Spicetify Settings page. These render the client's own section
|
|
183
|
+
// and row structure rather than capturing its route-local components.
|
|
143
184
|
export const SettingsSection: React.FC<{ title?: React.ReactNode; children: React.ReactNode }> = (props) => (
|
|
144
185
|
<div className={SETTINGS_SECTION_CLASS}>
|
|
145
|
-
{props.title === undefined ? null : <h2>{props.title}</h2>}
|
|
186
|
+
{props.title === undefined ? null : <h2 className={SETTINGS_SECTION_HEADING_CLASS}>{props.title}</h2>}
|
|
146
187
|
{props.children}
|
|
147
188
|
</div>
|
|
148
189
|
);
|
|
@@ -155,18 +196,79 @@ export const SettingsRow: React.FC<{ label: React.ReactNode; htmlFor?: string; c
|
|
|
155
196
|
) => (
|
|
156
197
|
<div className={SETTINGS_ROW_CLASS}>
|
|
157
198
|
<div className={SETTINGS_ROW_LABEL_CLASS}>
|
|
158
|
-
{props.htmlFor === undefined ?
|
|
199
|
+
{props.htmlFor === undefined ? (
|
|
200
|
+
<span className={SETTINGS_ROW_TEXT_CLASS}>{props.label}</span>
|
|
201
|
+
) : (
|
|
202
|
+
<label className={SETTINGS_ROW_TEXT_CLASS} htmlFor={props.htmlFor}>
|
|
203
|
+
{props.label}
|
|
204
|
+
</label>
|
|
205
|
+
)}
|
|
159
206
|
</div>
|
|
160
207
|
<div className={SETTINGS_ROW_CONTROL_CLASS}>{props.children}</div>
|
|
161
208
|
</div>
|
|
162
209
|
);
|
|
163
210
|
|
|
211
|
+
export const SettingsLabel: React.FC<{ label: React.ReactNode; description?: React.ReactNode }> = (props) => (
|
|
212
|
+
<span className={SETTINGS_LABEL_COPY_CLASS}>
|
|
213
|
+
<span>{props.label}</span>
|
|
214
|
+
{props.description === undefined ? null : <span className={SETTINGS_HELP_TEXT_CLASS}>{props.description}</span>}
|
|
215
|
+
</span>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
export const SettingsActions: React.FC<{ children: React.ReactNode }> = (props) => (
|
|
219
|
+
<span className={SETTINGS_ACTION_GROUP_CLASS}>{props.children}</span>
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
export const SettingsButtonRow: React.FC<{
|
|
223
|
+
label: React.ReactNode;
|
|
224
|
+
description?: React.ReactNode;
|
|
225
|
+
buttonLabel: React.ReactNode;
|
|
226
|
+
disabled?: boolean;
|
|
227
|
+
variant?: ButtonVariant;
|
|
228
|
+
onClick: () => void;
|
|
229
|
+
}> = (props) => (
|
|
230
|
+
<SettingsRow label={<SettingsLabel label={props.label} description={props.description} />}>
|
|
231
|
+
<Button variant={props.variant ?? "secondary"} disabled={props.disabled} onClick={props.onClick}>
|
|
232
|
+
{props.buttonLabel}
|
|
233
|
+
</Button>
|
|
234
|
+
</SettingsRow>
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
export const SettingsTextInputRow: React.FC<{
|
|
238
|
+
label: React.ReactNode;
|
|
239
|
+
description?: React.ReactNode;
|
|
240
|
+
value: string;
|
|
241
|
+
placeholder?: string;
|
|
242
|
+
ariaLabel?: string;
|
|
243
|
+
onInput: (value: string) => void;
|
|
244
|
+
actionLabel?: React.ReactNode;
|
|
245
|
+
actionDisabled?: boolean;
|
|
246
|
+
onAction?: () => void;
|
|
247
|
+
}> = (props) => (
|
|
248
|
+
<SettingsRow label={<SettingsLabel label={props.label} description={props.description} />}>
|
|
249
|
+
<SettingsActions>
|
|
250
|
+
<TextInput
|
|
251
|
+
value={props.value}
|
|
252
|
+
placeholder={props.placeholder}
|
|
253
|
+
ariaLabel={props.ariaLabel}
|
|
254
|
+
onInput={props.onInput}
|
|
255
|
+
/>
|
|
256
|
+
{props.actionLabel === undefined || props.onAction === undefined ? null : (
|
|
257
|
+
<Button variant="secondary" disabled={props.actionDisabled} onClick={props.onAction}>
|
|
258
|
+
{props.actionLabel}
|
|
259
|
+
</Button>
|
|
260
|
+
)}
|
|
261
|
+
</SettingsActions>
|
|
262
|
+
</SettingsRow>
|
|
263
|
+
);
|
|
264
|
+
|
|
164
265
|
// The client's own switch structure around a native checkbox. Its semantic
|
|
165
266
|
// classes are recreated by the CLI css-map, so Spotify and themes style this
|
|
166
267
|
// exactly like the adjacent settings controls.
|
|
167
268
|
export const Toggle: React.FC<{
|
|
168
269
|
id?: string;
|
|
169
270
|
ariaLabel?: string;
|
|
271
|
+
disabled?: boolean;
|
|
170
272
|
value: boolean;
|
|
171
273
|
onChange: (value: boolean) => void;
|
|
172
274
|
}> = (props) => (
|
|
@@ -177,6 +279,7 @@ export const Toggle: React.FC<{
|
|
|
177
279
|
id={props.id}
|
|
178
280
|
aria-label={props.ariaLabel}
|
|
179
281
|
checked={props.value}
|
|
282
|
+
disabled={props.disabled}
|
|
180
283
|
onKeyDown={activateToggleOnKeyDown}
|
|
181
284
|
onChange={(e) => props.onChange((e.target as HTMLInputElement).checked)}
|
|
182
285
|
/>
|
|
@@ -186,6 +289,46 @@ export const Toggle: React.FC<{
|
|
|
186
289
|
</label>
|
|
187
290
|
);
|
|
188
291
|
|
|
292
|
+
export const SettingsProviderRow: React.FC<{
|
|
293
|
+
label: string;
|
|
294
|
+
description?: React.ReactNode;
|
|
295
|
+
value: boolean;
|
|
296
|
+
disabled?: boolean;
|
|
297
|
+
disabledReason?: string;
|
|
298
|
+
index: number;
|
|
299
|
+
total: number;
|
|
300
|
+
onChange: (value: boolean) => void;
|
|
301
|
+
onMove: (direction: -1 | 1) => void;
|
|
302
|
+
}> = (props) => {
|
|
303
|
+
const id = React.useId();
|
|
304
|
+
const toggle = (
|
|
305
|
+
<Toggle
|
|
306
|
+
id={id}
|
|
307
|
+
ariaLabel={`${props.label} provider`}
|
|
308
|
+
value={props.value}
|
|
309
|
+
disabled={props.disabled}
|
|
310
|
+
onChange={props.onChange}
|
|
311
|
+
/>
|
|
312
|
+
);
|
|
313
|
+
return (
|
|
314
|
+
<SettingsRow label={<SettingsLabel label={props.label} description={props.description} />} htmlFor={id}>
|
|
315
|
+
<SettingsActions>
|
|
316
|
+
<ReorderButtons
|
|
317
|
+
label={props.label}
|
|
318
|
+
disableUp={props.index === 0}
|
|
319
|
+
disableDown={props.index === props.total - 1}
|
|
320
|
+
onMove={props.onMove}
|
|
321
|
+
/>
|
|
322
|
+
{props.disabled && props.disabledReason ? (
|
|
323
|
+
<Tooltip label={props.disabledReason}>{toggle}</Tooltip>
|
|
324
|
+
) : (
|
|
325
|
+
toggle
|
|
326
|
+
)}
|
|
327
|
+
</SettingsActions>
|
|
328
|
+
</SettingsRow>
|
|
329
|
+
);
|
|
330
|
+
};
|
|
331
|
+
|
|
189
332
|
// The "module with one boolean setting" pattern as a single row for the
|
|
190
333
|
// `settingsRow` register. `getValue` is read lazily on every mount so
|
|
191
334
|
// reopening settings always reflects the current state; the element itself
|
|
@@ -275,34 +418,41 @@ export const ConfirmButton: React.FC<{
|
|
|
275
418
|
// transformed ancestor (the norm for Spotify's scroll containers) would
|
|
276
419
|
// otherwise be clipped/offset to that ancestor. The backdrop and the ×
|
|
277
420
|
// button both call onClose.
|
|
278
|
-
export const Dialog: React.FC<{
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
421
|
+
export const Dialog: React.FC<{
|
|
422
|
+
title: string;
|
|
423
|
+
onClose: () => void;
|
|
424
|
+
size?: "normal" | "large";
|
|
425
|
+
children: React.ReactNode;
|
|
426
|
+
}> = (props) => {
|
|
427
|
+
const dialogRef = React.useRef<HTMLDivElement>(null);
|
|
428
|
+
const onCloseRef = React.useRef(props.onClose);
|
|
429
|
+
const titleId = React.useId();
|
|
430
|
+
onCloseRef.current = props.onClose;
|
|
283
431
|
React.useEffect(() => {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
};
|
|
291
|
-
document.addEventListener("keydown", onKey, true);
|
|
292
|
-
return () => document.removeEventListener("keydown", onKey, true);
|
|
293
|
-
}, [onClose]);
|
|
432
|
+
const dialog = dialogRef.current;
|
|
433
|
+
if (!dialog) return;
|
|
434
|
+
return activateDialog(dialog, () => onCloseRef.current());
|
|
435
|
+
}, []);
|
|
436
|
+
const dialogClass = props.size === "large" ? `${DIALOG_CLASS} ${DIALOG_LARGE_CLASS}` : DIALOG_CLASS;
|
|
294
437
|
|
|
295
438
|
return ReactDOM.createPortal(
|
|
296
439
|
<div
|
|
297
440
|
className={SCRIM_CLASS}
|
|
298
441
|
onClick={(e) => {
|
|
299
|
-
if (e.target === e.currentTarget) onClose();
|
|
442
|
+
if (e.target === e.currentTarget) props.onClose();
|
|
300
443
|
}}
|
|
301
444
|
>
|
|
302
|
-
<div
|
|
445
|
+
<div
|
|
446
|
+
ref={dialogRef}
|
|
447
|
+
className={dialogClass}
|
|
448
|
+
role="dialog"
|
|
449
|
+
aria-modal="true"
|
|
450
|
+
aria-labelledby={titleId}
|
|
451
|
+
tabIndex={-1}
|
|
452
|
+
>
|
|
303
453
|
<div className={DIALOG_HEADER_CLASS}>
|
|
304
|
-
<h2>{props.title}</h2>
|
|
305
|
-
<IconButton ariaLabel="Close" onClick={onClose}>
|
|
454
|
+
<h2 id={titleId}>{props.title}</h2>
|
|
455
|
+
<IconButton ariaLabel="Close" onClick={props.onClose}>
|
|
306
456
|
×
|
|
307
457
|
</IconButton>
|
|
308
458
|
</div>
|
package/vendor/stdlib/mod.ts
CHANGED
|
@@ -18,9 +18,12 @@ export * from "./lib/primitives-vanilla.ts";
|
|
|
18
18
|
export * from "./src/storage.ts";
|
|
19
19
|
export * from "./src/wpunpk.ts";
|
|
20
20
|
export * from "./src/logger.ts";
|
|
21
|
+
export { display as displayModal, hide as hideModal, type ModalOptions } from "./lib/modal.tsx";
|
|
22
|
+
export { React, ReactDOM, ReactDOMServer } from "./src/expose/React.ts";
|
|
23
|
+
export { Platform } from "./src/expose/Platform.ts";
|
|
24
|
+
export { createIconComponent } from "./src/createIconComponent.tsx";
|
|
25
|
+
export { startCase } from "./deps.ts";
|
|
21
26
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
export
|
|
25
|
-
export * from "./src/webpack/ComponentLibrary.xpui.ts";
|
|
26
|
-
export * from "./src/webpack/ReactComponents.xpui.ts";
|
|
27
|
+
// Palette Manager needs the client's Color constructor and its companion
|
|
28
|
+
// type. Export that semantic capability without exposing the webpack module.
|
|
29
|
+
export { Color } from "./src/webpack/misc.xpui.ts";
|
|
@@ -13,10 +13,28 @@
|
|
|
13
13
|
* modules.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import type { ModalOptions } from "../lib/modal.tsx";
|
|
17
|
+
|
|
16
18
|
type SpicetifyRuntime = typeof Spicetify;
|
|
17
19
|
|
|
18
20
|
export type PlayerState = Spicetify.PlayerState;
|
|
19
21
|
|
|
22
|
+
export interface PopupModalCompatibility {
|
|
23
|
+
display(options: ModalOptions): void;
|
|
24
|
+
hide(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DaemonCapabilities {
|
|
28
|
+
available(): Promise<boolean>;
|
|
29
|
+
apply(): Promise<unknown>;
|
|
30
|
+
blockUpdates(): Promise<unknown>;
|
|
31
|
+
unblockUpdates(): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SnackbarCapabilities {
|
|
35
|
+
enqueueSnackbar(message: string, options?: { variant?: string }): unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export interface ClientCapabilities {
|
|
21
39
|
readonly player: typeof Spicetify.Player;
|
|
22
40
|
readonly platform: typeof Spicetify.Platform;
|
|
@@ -30,9 +48,14 @@ export interface ClientCapabilities {
|
|
|
30
48
|
readonly keyboard: typeof Spicetify.Keyboard;
|
|
31
49
|
readonly mousetrap: typeof Spicetify.Mousetrap;
|
|
32
50
|
readonly contextMenu: typeof Spicetify.ContextMenu;
|
|
33
|
-
|
|
51
|
+
/** @deprecated Import displayModal and hideModal from /modules/stdlib/mod.ts. */
|
|
52
|
+
readonly popupModal: PopupModalCompatibility;
|
|
34
53
|
readonly config: typeof Spicetify.Config;
|
|
35
54
|
readonly modules: typeof Spicetify.Modules;
|
|
55
|
+
/** V3 manifest version, with the legacy wrapper config as a fallback. */
|
|
56
|
+
readonly spicetifyVersion: string | undefined;
|
|
57
|
+
readonly daemon: DaemonCapabilities | undefined;
|
|
58
|
+
readonly snackbar: SnackbarCapabilities | undefined;
|
|
36
59
|
readonly react: typeof Spicetify.React;
|
|
37
60
|
readonly reactDOM: typeof Spicetify.ReactDOM;
|
|
38
61
|
readonly tippy: typeof Spicetify.Tippy;
|
|
@@ -47,6 +70,26 @@ const runtime = (): SpicetifyRuntime => {
|
|
|
47
70
|
return value;
|
|
48
71
|
};
|
|
49
72
|
|
|
73
|
+
const nonEmptyVersion = (value: unknown): string | undefined =>
|
|
74
|
+
typeof value === "string" && value.trim() ? value : undefined;
|
|
75
|
+
|
|
76
|
+
let modalRequestGeneration = 0;
|
|
77
|
+
const reportModalFailure = (error: unknown) => console.error("[stdlib] failed to load the owned modal:", error);
|
|
78
|
+
const popupModalCompatibility: PopupModalCompatibility = {
|
|
79
|
+
display(options) {
|
|
80
|
+
const generation = ++modalRequestGeneration;
|
|
81
|
+
void import("../lib/modal.tsx")
|
|
82
|
+
.then(({ display }) => {
|
|
83
|
+
if (generation === modalRequestGeneration) display(options);
|
|
84
|
+
})
|
|
85
|
+
.catch(reportModalFailure);
|
|
86
|
+
},
|
|
87
|
+
hide() {
|
|
88
|
+
++modalRequestGeneration;
|
|
89
|
+
void import("../lib/modal.tsx").then(({ hide }) => hide()).catch(reportModalFailure);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
50
93
|
export const client: ClientCapabilities = {
|
|
51
94
|
get player() {
|
|
52
95
|
return runtime().Player;
|
|
@@ -85,7 +128,7 @@ export const client: ClientCapabilities = {
|
|
|
85
128
|
return runtime().ContextMenu;
|
|
86
129
|
},
|
|
87
130
|
get popupModal() {
|
|
88
|
-
return
|
|
131
|
+
return popupModalCompatibility;
|
|
89
132
|
},
|
|
90
133
|
get config() {
|
|
91
134
|
return runtime().Config;
|
|
@@ -93,6 +136,26 @@ export const client: ClientCapabilities = {
|
|
|
93
136
|
get modules() {
|
|
94
137
|
return runtime().Modules;
|
|
95
138
|
},
|
|
139
|
+
get spicetifyVersion() {
|
|
140
|
+
const current = runtime() as unknown as {
|
|
141
|
+
Modules?: {
|
|
142
|
+
manifest?: { cliVersion?: unknown };
|
|
143
|
+
registry?: { manifest?: { cliVersion?: unknown } };
|
|
144
|
+
};
|
|
145
|
+
Config?: { version?: unknown };
|
|
146
|
+
};
|
|
147
|
+
return (
|
|
148
|
+
nonEmptyVersion(current.Modules?.manifest?.cliVersion) ??
|
|
149
|
+
nonEmptyVersion(current.Modules?.registry?.manifest?.cliVersion) ??
|
|
150
|
+
nonEmptyVersion(current.Config?.version)
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
get daemon() {
|
|
154
|
+
return (runtime() as unknown as { Daemon?: DaemonCapabilities }).Daemon;
|
|
155
|
+
},
|
|
156
|
+
get snackbar() {
|
|
157
|
+
return (runtime() as unknown as { Snackbar?: SnackbarCapabilities }).Snackbar;
|
|
158
|
+
},
|
|
96
159
|
get react() {
|
|
97
160
|
return runtime().React;
|
|
98
161
|
},
|
|
@@ -17,7 +17,10 @@ function resolvePlatform(): any {
|
|
|
17
17
|
return cached ?? undefined;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Typed as the generated Platform surface; the Proxy target is an empty
|
|
21
|
+
// shell whose members only exist at get time, so the assertion is the
|
|
22
|
+
// honest spelling.
|
|
23
|
+
export const Platform = new Proxy(
|
|
21
24
|
{},
|
|
22
25
|
{
|
|
23
26
|
get: (_, key) => {
|
|
@@ -45,4 +48,4 @@ export const Platform: any = new Proxy(
|
|
|
45
48
|
return false;
|
|
46
49
|
},
|
|
47
50
|
},
|
|
48
|
-
);
|
|
51
|
+
) as typeof Spicetify.Platform;
|