@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
|
@@ -508,16 +508,28 @@ declare namespace Spicetify {
|
|
|
508
508
|
const CosmosAPI: any;
|
|
509
509
|
/**
|
|
510
510
|
* Reaches hosts that refuse the client's origin, through the local daemon
|
|
511
|
-
* when it is running and the hosted proxy otherwise.
|
|
512
|
-
*
|
|
511
|
+
* when it is running and the hosted proxy otherwise. Its global
|
|
512
|
+
* configuration API can replace the chain with one custom template.
|
|
513
513
|
*/
|
|
514
514
|
namespace CORSProxy {
|
|
515
|
+
type Mode = "automatic" | "custom";
|
|
516
|
+
interface Configuration {
|
|
517
|
+
mode: Mode;
|
|
518
|
+
template: string | null;
|
|
519
|
+
automaticTemplates: [string, string];
|
|
520
|
+
}
|
|
515
521
|
/** The proxied URL for `target` using the first template in the chain. */
|
|
516
522
|
function url(target: string): string;
|
|
517
523
|
/** Fetches `target` proxied, falling back to the next template only when a request gets no answer. */
|
|
518
524
|
function fetch(target: string, options?: RequestInit): Promise<Response>;
|
|
519
525
|
/** The active chain, in order of preference. */
|
|
520
526
|
function templates(): string[];
|
|
527
|
+
/** Describes the global proxy mode without exposing its storage details. */
|
|
528
|
+
function configuration(): Configuration;
|
|
529
|
+
/** Applies the automatic chain or a validated custom URL template immediately. */
|
|
530
|
+
function configure(options: { mode: "automatic" } | { mode: "custom"; template: string }): Configuration;
|
|
531
|
+
/** True when a custom absolute URL template contains the required `{url}` placeholder. */
|
|
532
|
+
function isValidTemplate(template: string): boolean;
|
|
521
533
|
}
|
|
522
534
|
/**
|
|
523
535
|
* Async wrappers of CosmosAPI
|
|
@@ -819,12 +831,12 @@ declare namespace Spicetify {
|
|
|
819
831
|
};
|
|
820
832
|
|
|
821
833
|
/**
|
|
822
|
-
* The client's internal platform APIs
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
834
|
+
* The client's internal platform APIs, typed from platform.d.ts, the
|
|
835
|
+
* committed snapshot scripts/platform-types.ts regenerates per Spotify
|
|
836
|
+
* build. The snapshot's root is the property form the client uses
|
|
837
|
+
* (`Platform.LibraryAPI`).
|
|
826
838
|
*/
|
|
827
|
-
type PlatformAutoGen = import("
|
|
839
|
+
type PlatformAutoGen = import("./platform").Platform;
|
|
828
840
|
// One level of member names per API — enough to autocomplete which methods an
|
|
829
841
|
// API exposes (Platform.LibraryAPI.getContents…), with every value left `any`.
|
|
830
842
|
// The generated method signatures are unreliable (arg counts and nullability
|
|
@@ -836,22 +848,10 @@ declare namespace Spicetify {
|
|
|
836
848
|
: T extends object
|
|
837
849
|
? { [M in keyof T]: any }
|
|
838
850
|
: T;
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
& {
|
|
844
|
-
[K in keyof PlatformAutoGen]: PlatformAutoGen[K] extends (...args: any[]) => infer R
|
|
845
|
-
? (...args: any[]) => Shape<R>
|
|
846
|
-
: PlatformAutoGen[K];
|
|
847
|
-
}
|
|
848
|
-
& {
|
|
849
|
-
[K in keyof PlatformAutoGen as K extends `get${infer Name}` ? Name : never]: PlatformAutoGen[K] extends (
|
|
850
|
-
...args: any[]
|
|
851
|
-
) => infer R
|
|
852
|
-
? Shape<R>
|
|
853
|
-
: never;
|
|
854
|
-
};
|
|
851
|
+
// The Record<string, any> tail keeps unknown keys open.
|
|
852
|
+
type PlatformApis = {
|
|
853
|
+
[K in keyof PlatformAutoGen]: Shape<PlatformAutoGen[K]>;
|
|
854
|
+
};
|
|
855
855
|
const Platform: PlatformApis & Record<string, any>;
|
|
856
856
|
/**
|
|
857
857
|
* Queue object contains list of queuing tracks,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const FOCUSABLE_SELECTOR = [
|
|
7
|
+
"button:not([disabled])",
|
|
8
|
+
"[href]",
|
|
9
|
+
"input:not([disabled])",
|
|
10
|
+
"select:not([disabled])",
|
|
11
|
+
"textarea:not([disabled])",
|
|
12
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
13
|
+
].join(",");
|
|
14
|
+
|
|
15
|
+
const focusableElements = (dialog: HTMLElement): HTMLElement[] =>
|
|
16
|
+
[...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
17
|
+
(element) => !element.hidden && element.getAttribute("aria-hidden") !== "true",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export function activateDialog(dialog: HTMLElement, close: () => void): () => void {
|
|
21
|
+
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
22
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
23
|
+
if (event.key === "Escape") {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
event.stopPropagation();
|
|
26
|
+
close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (event.key !== "Tab") return;
|
|
30
|
+
const focusable = focusableElements(dialog);
|
|
31
|
+
if (focusable.length === 0) {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
dialog.focus();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const first = focusable[0];
|
|
37
|
+
const last = focusable.at(-1)!;
|
|
38
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
last.focus();
|
|
41
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
first.focus();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
48
|
+
(focusableElements(dialog)[0] ?? dialog).focus();
|
|
49
|
+
return () => {
|
|
50
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
51
|
+
if (previousFocus?.isConnected) previousFocus.focus();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface AnchorRect {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
right: number;
|
|
10
|
+
bottom: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SurfaceSize {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ViewportSize {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FloatingPositionOptions {
|
|
24
|
+
align: "center" | "start";
|
|
25
|
+
gap: number;
|
|
26
|
+
margin?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FloatingPosition {
|
|
30
|
+
left: number;
|
|
31
|
+
top: number;
|
|
32
|
+
placement: "above" | "below";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const clamp = (value: number, minimum: number, maximum: number): number =>
|
|
36
|
+
Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
|
|
37
|
+
|
|
38
|
+
export function calculateFloatingPosition(
|
|
39
|
+
anchor: AnchorRect,
|
|
40
|
+
surface: SurfaceSize,
|
|
41
|
+
viewport: ViewportSize,
|
|
42
|
+
options: FloatingPositionOptions,
|
|
43
|
+
): FloatingPosition {
|
|
44
|
+
const margin = options.margin ?? 8;
|
|
45
|
+
const spaceBelow = viewport.height - anchor.bottom - margin;
|
|
46
|
+
const spaceAbove = anchor.top - margin;
|
|
47
|
+
const placement = spaceBelow >= surface.height + options.gap || spaceBelow >= spaceAbove ? "below" : "above";
|
|
48
|
+
const desiredTop = placement === "below" ? anchor.bottom + options.gap : anchor.top - options.gap - surface.height;
|
|
49
|
+
const anchorCenter = anchor.left + (anchor.right - anchor.left) / 2;
|
|
50
|
+
const desiredLeft = options.align === "center" ? anchorCenter - surface.width / 2 : anchor.left;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
left: Math.round(clamp(desiredLeft, margin, viewport.width - surface.width - margin)),
|
|
54
|
+
top: Math.round(clamp(desiredTop, margin, viewport.height - surface.height - margin)),
|
|
55
|
+
placement,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { React, ReactDOM } from "../src/expose/React.ts";
|
|
7
|
+
import { calculateFloatingPosition } from "./floating-position.ts";
|
|
8
|
+
|
|
9
|
+
const TOOLTIP_HIDE_DELAY_MS = 300;
|
|
10
|
+
const FOCUSABLE_SELECTOR =
|
|
11
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
12
|
+
|
|
13
|
+
type FloatingRole = "dialog" | "menu";
|
|
14
|
+
type TriggerProps = React.AriaAttributes & {
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function useFloatingPlacement(
|
|
20
|
+
open: boolean,
|
|
21
|
+
anchorRef: React.RefObject<HTMLElement | null>,
|
|
22
|
+
surfaceRef: React.RefObject<HTMLElement | null>,
|
|
23
|
+
align: "center" | "start",
|
|
24
|
+
gap: number,
|
|
25
|
+
): void {
|
|
26
|
+
React.useLayoutEffect(() => {
|
|
27
|
+
if (!open) return;
|
|
28
|
+
const anchor = anchorRef.current;
|
|
29
|
+
const surface = surfaceRef.current;
|
|
30
|
+
if (!anchor || !surface) return;
|
|
31
|
+
|
|
32
|
+
const place = () => {
|
|
33
|
+
if (!anchor.isConnected || !surface.isConnected) return;
|
|
34
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
35
|
+
const surfaceRect = surface.getBoundingClientRect();
|
|
36
|
+
const position = calculateFloatingPosition(
|
|
37
|
+
anchorRect,
|
|
38
|
+
surfaceRect,
|
|
39
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
40
|
+
{ align, gap },
|
|
41
|
+
);
|
|
42
|
+
surface.style.left = `${position.left}px`;
|
|
43
|
+
surface.style.top = `${position.top}px`;
|
|
44
|
+
surface.style.visibility = "visible";
|
|
45
|
+
surface.dataset.placement = position.placement;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
place();
|
|
49
|
+
window.addEventListener("resize", place);
|
|
50
|
+
document.addEventListener("scroll", place, true);
|
|
51
|
+
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(place);
|
|
52
|
+
observer?.observe(anchor);
|
|
53
|
+
observer?.observe(surface);
|
|
54
|
+
return () => {
|
|
55
|
+
window.removeEventListener("resize", place);
|
|
56
|
+
document.removeEventListener("scroll", place, true);
|
|
57
|
+
observer?.disconnect();
|
|
58
|
+
};
|
|
59
|
+
}, [align, anchorRef, gap, open, surfaceRef]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function focusTrigger(anchor: HTMLElement | null): void {
|
|
63
|
+
anchor?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function moveMenuFocus(event: React.KeyboardEvent<HTMLElement>): void {
|
|
67
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
68
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([disabled])')];
|
|
69
|
+
if (!items.length) return;
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
|
72
|
+
const nextIndex =
|
|
73
|
+
event.key === "Home"
|
|
74
|
+
? 0
|
|
75
|
+
: event.key === "End"
|
|
76
|
+
? items.length - 1
|
|
77
|
+
: (currentIndex + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
|
78
|
+
items[nextIndex]?.focus();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function usePopoverDismissal(
|
|
82
|
+
open: boolean,
|
|
83
|
+
close: () => void,
|
|
84
|
+
anchorRef: React.RefObject<HTMLElement | null>,
|
|
85
|
+
surfaceRef: React.RefObject<HTMLElement | null>,
|
|
86
|
+
): void {
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (!open) return;
|
|
89
|
+
const onDocumentMouseDown = (event: MouseEvent) => {
|
|
90
|
+
const target = event.target as Node;
|
|
91
|
+
if (!anchorRef.current?.contains(target) && !surfaceRef.current?.contains(target)) close();
|
|
92
|
+
};
|
|
93
|
+
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
|
94
|
+
if (event.key !== "Escape") return;
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
event.stopPropagation();
|
|
97
|
+
close();
|
|
98
|
+
requestAnimationFrame(() => focusTrigger(anchorRef.current));
|
|
99
|
+
};
|
|
100
|
+
const onDocumentFocus = (event: FocusEvent) => {
|
|
101
|
+
const target = event.target as Node;
|
|
102
|
+
if (!anchorRef.current?.contains(target) && !surfaceRef.current?.contains(target)) close();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
document.addEventListener("mousedown", onDocumentMouseDown, true);
|
|
106
|
+
document.addEventListener("keydown", onDocumentKeyDown, true);
|
|
107
|
+
document.addEventListener("focusin", onDocumentFocus, true);
|
|
108
|
+
return () => {
|
|
109
|
+
document.removeEventListener("mousedown", onDocumentMouseDown, true);
|
|
110
|
+
document.removeEventListener("keydown", onDocumentKeyDown, true);
|
|
111
|
+
document.removeEventListener("focusin", onDocumentFocus, true);
|
|
112
|
+
};
|
|
113
|
+
}, [anchorRef, close, open, surfaceRef]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const Tooltip: React.FC<{
|
|
117
|
+
label: React.ReactNode;
|
|
118
|
+
children: React.ReactElement<TriggerProps>;
|
|
119
|
+
}> = (props) => {
|
|
120
|
+
const id = React.useId();
|
|
121
|
+
const anchorRef = React.useRef<HTMLSpanElement>(null);
|
|
122
|
+
const tooltipRef = React.useRef<HTMLDivElement>(null);
|
|
123
|
+
const hideTimer = React.useRef<number>();
|
|
124
|
+
const [visible, setVisible] = React.useState(false);
|
|
125
|
+
const cancelHide = React.useCallback(() => window.clearTimeout(hideTimer.current), []);
|
|
126
|
+
const show = React.useCallback(() => {
|
|
127
|
+
cancelHide();
|
|
128
|
+
setVisible(true);
|
|
129
|
+
}, [cancelHide]);
|
|
130
|
+
const dismiss = React.useCallback(() => {
|
|
131
|
+
cancelHide();
|
|
132
|
+
setVisible(false);
|
|
133
|
+
}, [cancelHide]);
|
|
134
|
+
const scheduleHide = React.useCallback(() => {
|
|
135
|
+
cancelHide();
|
|
136
|
+
hideTimer.current = window.setTimeout(() => setVisible(false), TOOLTIP_HIDE_DELAY_MS);
|
|
137
|
+
}, [cancelHide]);
|
|
138
|
+
useFloatingPlacement(visible, anchorRef, tooltipRef, "center", 8);
|
|
139
|
+
|
|
140
|
+
React.useEffect(() => () => cancelHide(), [cancelHide]);
|
|
141
|
+
React.useEffect(() => {
|
|
142
|
+
if (!visible) return;
|
|
143
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
144
|
+
if (event.key !== "Escape") return;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
event.stopPropagation();
|
|
147
|
+
cancelHide();
|
|
148
|
+
setVisible(false);
|
|
149
|
+
};
|
|
150
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
151
|
+
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
152
|
+
}, [cancelHide, visible]);
|
|
153
|
+
|
|
154
|
+
const existingDescription = props.children.props["aria-describedby"];
|
|
155
|
+
const describedBy = [existingDescription, id].filter(Boolean).join(" ");
|
|
156
|
+
const child = React.cloneElement(props.children, { "aria-describedby": describedBy });
|
|
157
|
+
const disabled = props.children.props.disabled === true;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<span
|
|
161
|
+
ref={anchorRef}
|
|
162
|
+
className="spicetify-floating-anchor"
|
|
163
|
+
tabIndex={disabled ? 0 : undefined}
|
|
164
|
+
aria-describedby={disabled ? id : undefined}
|
|
165
|
+
onClickCapture={dismiss}
|
|
166
|
+
onPointerEnter={show}
|
|
167
|
+
onPointerLeave={scheduleHide}
|
|
168
|
+
onFocusCapture={show}
|
|
169
|
+
onBlurCapture={scheduleHide}
|
|
170
|
+
>
|
|
171
|
+
{child}
|
|
172
|
+
{visible
|
|
173
|
+
? ReactDOM.createPortal(
|
|
174
|
+
<div
|
|
175
|
+
ref={tooltipRef}
|
|
176
|
+
id={id}
|
|
177
|
+
role="tooltip"
|
|
178
|
+
className="spicetify-tooltip"
|
|
179
|
+
style={{ visibility: "hidden" }}
|
|
180
|
+
onPointerEnter={cancelHide}
|
|
181
|
+
onPointerLeave={scheduleHide}
|
|
182
|
+
>
|
|
183
|
+
{props.label}
|
|
184
|
+
</div>,
|
|
185
|
+
document.body,
|
|
186
|
+
)
|
|
187
|
+
: null}
|
|
188
|
+
</span>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const Popover: React.FC<{
|
|
193
|
+
ariaLabel: string;
|
|
194
|
+
children: React.ReactElement<TriggerProps>;
|
|
195
|
+
content: React.ReactNode | ((close: () => void) => React.ReactNode);
|
|
196
|
+
role?: FloatingRole;
|
|
197
|
+
align?: "center" | "start";
|
|
198
|
+
className?: string;
|
|
199
|
+
tooltip?: React.ReactNode;
|
|
200
|
+
onClose?: () => void;
|
|
201
|
+
}> = (props) => {
|
|
202
|
+
const id = React.useId();
|
|
203
|
+
const anchorRef = React.useRef<HTMLSpanElement>(null);
|
|
204
|
+
const surfaceRef = React.useRef<HTMLDivElement>(null);
|
|
205
|
+
const openRef = React.useRef(false);
|
|
206
|
+
const [open, setOpen] = React.useState(false);
|
|
207
|
+
const close = React.useCallback(() => {
|
|
208
|
+
if (!openRef.current) return;
|
|
209
|
+
openRef.current = false;
|
|
210
|
+
setOpen(false);
|
|
211
|
+
props.onClose?.();
|
|
212
|
+
}, [props.onClose]);
|
|
213
|
+
const toggle = () => {
|
|
214
|
+
if (openRef.current) {
|
|
215
|
+
close();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
openRef.current = true;
|
|
219
|
+
setOpen(true);
|
|
220
|
+
};
|
|
221
|
+
const role = props.role ?? "dialog";
|
|
222
|
+
useFloatingPlacement(open, anchorRef, surfaceRef, props.align ?? "start", 8);
|
|
223
|
+
usePopoverDismissal(open, close, anchorRef, surfaceRef);
|
|
224
|
+
React.useEffect(() => {
|
|
225
|
+
if (!open) return;
|
|
226
|
+
const frame = requestAnimationFrame(() => {
|
|
227
|
+
const surface = surfaceRef.current;
|
|
228
|
+
(surface?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? surface)?.focus();
|
|
229
|
+
});
|
|
230
|
+
return () => cancelAnimationFrame(frame);
|
|
231
|
+
}, [open]);
|
|
232
|
+
React.useEffect(
|
|
233
|
+
() => () => {
|
|
234
|
+
openRef.current = false;
|
|
235
|
+
},
|
|
236
|
+
[],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const originalClick = props.children.props.onClick;
|
|
240
|
+
const trigger = React.cloneElement(props.children, {
|
|
241
|
+
"aria-haspopup": role,
|
|
242
|
+
"aria-expanded": open,
|
|
243
|
+
"aria-controls": open ? id : undefined,
|
|
244
|
+
onClick: (event: React.MouseEvent<HTMLElement>) => {
|
|
245
|
+
originalClick?.(event);
|
|
246
|
+
if (!event.defaultPrevented) toggle();
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const describedTrigger = props.tooltip ? <Tooltip label={props.tooltip}>{trigger}</Tooltip> : trigger;
|
|
250
|
+
const surfaceClass = props.className
|
|
251
|
+
? `spicetify-popover spicetify-popover--owned ${props.className}`
|
|
252
|
+
: "spicetify-popover spicetify-popover--owned";
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<span ref={anchorRef} className="spicetify-floating-anchor">
|
|
256
|
+
{describedTrigger}
|
|
257
|
+
{open
|
|
258
|
+
? ReactDOM.createPortal(
|
|
259
|
+
<div
|
|
260
|
+
ref={surfaceRef}
|
|
261
|
+
id={id}
|
|
262
|
+
role={role}
|
|
263
|
+
aria-label={props.ariaLabel}
|
|
264
|
+
className={surfaceClass}
|
|
265
|
+
tabIndex={-1}
|
|
266
|
+
style={{ visibility: "hidden" }}
|
|
267
|
+
onKeyDown={role === "menu" ? moveMenuFocus : undefined}
|
|
268
|
+
>
|
|
269
|
+
{typeof props.content === "function" ? props.content(close) : props.content}
|
|
270
|
+
</div>,
|
|
271
|
+
document.body,
|
|
272
|
+
)
|
|
273
|
+
: null}
|
|
274
|
+
</span>
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const PopoverMenu: React.FC<{ children: React.ReactNode }> = (props) => (
|
|
279
|
+
<div className="spicetify-popover-menu">{props.children}</div>
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
export const PopoverMenuItem: React.FC<{
|
|
283
|
+
selected?: boolean;
|
|
284
|
+
disabled?: boolean;
|
|
285
|
+
onSelect: () => void;
|
|
286
|
+
children: React.ReactNode;
|
|
287
|
+
}> = (props) => (
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
role={props.selected === undefined ? "menuitem" : "menuitemradio"}
|
|
291
|
+
aria-checked={props.selected}
|
|
292
|
+
className="spicetify-popover-menu-item"
|
|
293
|
+
disabled={props.disabled}
|
|
294
|
+
onClick={props.onSelect}
|
|
295
|
+
>
|
|
296
|
+
<span>{props.children}</span>
|
|
297
|
+
</button>
|
|
298
|
+
);
|
|
@@ -1,128 +1,35 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
4
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { React } from "../src/expose/React.ts";
|
|
7
|
-
import { Locale } from "../src/webpack/misc.ts";
|
|
8
|
-
|
|
9
8
|
import RootRegistry from "../src/registers/root.ts";
|
|
9
|
+
import { Dialog } from "./primitives.tsx";
|
|
10
|
+
|
|
10
11
|
const [RootChildrenRegistry] = RootRegistry;
|
|
11
|
-
import { createIconComponent } from "../src/createIconComponent.tsx";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
export interface ModalOptions {
|
|
14
|
+
title: string;
|
|
15
|
+
content: React.ReactNode;
|
|
16
|
+
isLarge?: boolean;
|
|
17
|
+
}
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
hide();
|
|
19
|
+
let mountedModal: React.ReactElement | undefined;
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
export function display({ title, content, isLarge = false }: ModalOptions): void {
|
|
22
|
+
hide();
|
|
23
|
+
mountedModal = (
|
|
24
|
+
<Dialog title={title} size={isLarge ? "large" : "normal"} onClose={hide}>
|
|
25
|
+
{content}
|
|
26
|
+
</Dialog>
|
|
22
27
|
);
|
|
28
|
+
RootChildrenRegistry.add(mountedModal);
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
export function hide() {
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface PopupModalProps {
|
|
33
|
-
contentLabel: string;
|
|
34
|
-
children: React.ReactNode;
|
|
35
|
-
isEmbedWidgetGeneratorOrTrackCreditsModal: boolean;
|
|
31
|
+
export function hide(): void {
|
|
32
|
+
if (!mountedModal) return;
|
|
33
|
+
RootChildrenRegistry.delete(mountedModal);
|
|
34
|
+
mountedModal = undefined;
|
|
36
35
|
}
|
|
37
|
-
|
|
38
|
-
// Self-contained modal shell: client modal components (GenericModal, UI.*)
|
|
39
|
-
// require the client tree's contexts, which anchors don't have. The shell
|
|
40
|
-
// owns the backdrop and dismissal; the MAP-classed chrome keeps native
|
|
41
|
-
// styling.
|
|
42
|
-
const ModalShell = (props: { contentLabel: string; children: React.ReactNode }) => {
|
|
43
|
-
React.useEffect(() => {
|
|
44
|
-
const onKey = (e: KeyboardEvent) => {
|
|
45
|
-
if (e.key === "Escape") hide();
|
|
46
|
-
};
|
|
47
|
-
document.addEventListener("keydown", onKey);
|
|
48
|
-
return () => document.removeEventListener("keydown", onKey);
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div
|
|
53
|
-
role="dialog"
|
|
54
|
-
aria-label={props.contentLabel}
|
|
55
|
-
style={{
|
|
56
|
-
position: "fixed",
|
|
57
|
-
inset: 0,
|
|
58
|
-
zIndex: 100,
|
|
59
|
-
display: "flex",
|
|
60
|
-
alignItems: "center",
|
|
61
|
-
justifyContent: "center",
|
|
62
|
-
background: "rgba(0, 0, 0, 0.7)",
|
|
63
|
-
}}
|
|
64
|
-
onClick={(e) => {
|
|
65
|
-
if (e.target === e.currentTarget) hide();
|
|
66
|
-
}}
|
|
67
|
-
>
|
|
68
|
-
<div
|
|
69
|
-
style={{
|
|
70
|
-
background: "var(--spice-card, var(--background-elevated-base, #282828))",
|
|
71
|
-
color: "var(--spice-text, var(--text-base, #fff))",
|
|
72
|
-
borderRadius: "8px",
|
|
73
|
-
maxHeight: "80vh",
|
|
74
|
-
maxWidth: "90vw",
|
|
75
|
-
overflow: "auto",
|
|
76
|
-
}}
|
|
77
|
-
>
|
|
78
|
-
{props.children}
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const closeIcon =
|
|
85
|
-
"<path d='M2.47 2.47a.75.75 0 0 1 1.06 0L8 6.94l4.47-4.47a.75.75 0 1 1 1.06 1.06L9.06 8l4.47 4.47a.75.75 0 1 1-1.06 1.06L8 9.06l-4.47 4.47a.75.75 0 0 1-1.06-1.06L6.94 8 2.47 3.53a.75.75 0 0 1 0-1.06Z'/>";
|
|
86
|
-
|
|
87
|
-
const PopupModal = (props: PopupModalProps) => {
|
|
88
|
-
if (props.isEmbedWidgetGeneratorOrTrackCreditsModal) {
|
|
89
|
-
return (
|
|
90
|
-
<ModalShell contentLabel={props.contentLabel}>
|
|
91
|
-
<div className={MAP.modal.widget_generator.container} style={{ overflow: "scroll", width: "60vw" }}>
|
|
92
|
-
<div className={MAP.modal.widget_generator.header.container}>
|
|
93
|
-
<h1 className="encore-text encore-text-title-small">{props.contentLabel}</h1>
|
|
94
|
-
<button className={MAP.modal.widget_generator.header.close} onClick={hide}>
|
|
95
|
-
{createIconComponent({
|
|
96
|
-
icon: closeIcon,
|
|
97
|
-
"aria-label": Locale.get("close"),
|
|
98
|
-
})}
|
|
99
|
-
</button>
|
|
100
|
-
</div>
|
|
101
|
-
<div className={MAP.modal.widget_generator.content.container}>{props.children}</div>
|
|
102
|
-
</div>
|
|
103
|
-
</ModalShell>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<ModalShell contentLabel={props.contentLabel}>
|
|
109
|
-
<div className={MAP.modal.track_credits.container}>
|
|
110
|
-
<div className={MAP.modal.track_credits.header.container}>
|
|
111
|
-
<h1 className="encore-text encore-text-title-medium">{props.contentLabel}</h1>
|
|
112
|
-
<button
|
|
113
|
-
className={MAP.modal.track_credits.header.close}
|
|
114
|
-
aria-label={Locale.get("close")}
|
|
115
|
-
onClick={hide}
|
|
116
|
-
>
|
|
117
|
-
{createIconComponent({
|
|
118
|
-
icon: closeIcon,
|
|
119
|
-
"aria-label": Locale.get("close"),
|
|
120
|
-
iconSize: 18,
|
|
121
|
-
})}
|
|
122
|
-
</button>
|
|
123
|
-
</div>
|
|
124
|
-
<div className={MAP.modal.track_credits.content.container}>{props.children}</div>
|
|
125
|
-
</div>
|
|
126
|
-
</ModalShell>
|
|
127
|
-
);
|
|
128
|
-
};
|