@spicetify/kit 0.1.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/bin/spicetify-kit.js +4 -0
- package/dist/build.js +335 -0
- package/dist/check.js +284 -0
- package/dist/classmap.js +187 -0
- package/dist/cli.js +53 -0
- package/dist/create.js +429 -0
- package/dist/dev.js +100 -0
- package/dist/from-theme.js +96 -0
- package/dist/install.js +60 -0
- package/dist/launch.js +122 -0
- package/dist/pack.js +31 -0
- package/dist/push.js +211 -0
- package/dist/vault.js +108 -0
- package/package.json +49 -0
- package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
- package/vendor/shims/chunks.d.ts +10 -0
- package/vendor/shims/remote-modules.d.ts +6 -0
- package/vendor/shims/spicetify.d.ts +2437 -0
- package/vendor/stdlib/deps.ts +24 -0
- package/vendor/stdlib/index.ts +24 -0
- package/vendor/stdlib/lib/modal.tsx +128 -0
- package/vendor/stdlib/lib/popover.ts +84 -0
- package/vendor/stdlib/lib/primitives-classes.ts +66 -0
- package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
- package/vendor/stdlib/lib/primitives.tsx +314 -0
- package/vendor/stdlib/load.ts +17 -0
- package/vendor/stdlib/metadata.json +18 -0
- package/vendor/stdlib/mixin.ts +32 -0
- package/vendor/stdlib/mod.ts +26 -0
- package/vendor/stdlib/src/chunks.d.ts +10 -0
- package/vendor/stdlib/src/client.ts +114 -0
- package/vendor/stdlib/src/createIconComponent.tsx +29 -0
- package/vendor/stdlib/src/events.mix.ts +55 -0
- package/vendor/stdlib/src/events.ts +58 -0
- package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
- package/vendor/stdlib/src/expose/Platform.ts +48 -0
- package/vendor/stdlib/src/expose/React.ts +95 -0
- package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
- package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
- package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
- package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
- package/vendor/stdlib/src/expose/Tippy.ts +26 -0
- package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
- package/vendor/stdlib/src/expose/index.ts +14 -0
- package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
- package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
- package/vendor/stdlib/src/expose/react-shim.ts +116 -0
- package/vendor/stdlib/src/logger.ts +35 -0
- package/vendor/stdlib/src/playbar-compat.tsx +136 -0
- package/vendor/stdlib/src/registers/index.ts +176 -0
- package/vendor/stdlib/src/registers/menu.ts +160 -0
- package/vendor/stdlib/src/registers/mount.ts +307 -0
- package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
- package/vendor/stdlib/src/registers/navlink.tsx +165 -0
- package/vendor/stdlib/src/registers/order.ts +22 -0
- package/vendor/stdlib/src/registers/panel.ts +168 -0
- package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
- package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
- package/vendor/stdlib/src/registers/registry.ts +12 -0
- package/vendor/stdlib/src/registers/root.ts +101 -0
- package/vendor/stdlib/src/registers/route.ts +123 -0
- package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
- package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
- package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
- package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
- package/vendor/stdlib/src/storage.ts +163 -0
- package/vendor/stdlib/src/util.ts +62 -0
- package/vendor/stdlib/src/utils/index.ts +16 -0
- package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
- package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
- package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
- package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
- package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
- package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
- package/vendor/stdlib/src/webpack/React.ts +6 -0
- package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
- package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
- package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
- package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
- package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
- package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
- package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
- package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
- package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
- package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
- package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
- package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/URI.ts +82 -0
- package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
- package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
- package/vendor/stdlib/src/webpack/index.ts +186 -0
- package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
- package/vendor/stdlib/src/webpack/misc.ts +6 -0
- package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
- package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
- package/vendor/stdlib/src/wpunpk.ts +36 -0
- package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
4
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Prebundled vendor copy (one self-contained file; vendor/rxjs.d.ts carries
|
|
8
|
+
// the types), so stdlib boots without the network. The esm.sh imports this
|
|
9
|
+
// replaces were static, which put esm.sh in every client's boot path and
|
|
10
|
+
// made an offline boot fail the whole module graph. A bare "rxjs" import is
|
|
11
|
+
// no good either: the tree build preserves modules, so it splats the package
|
|
12
|
+
// into dist as dozens of files under the resolver's store path.
|
|
13
|
+
export { BehaviorSubject, Subject, Subscription } from "./vendor/rxjs.js";
|
|
14
|
+
|
|
15
|
+
// The one remaining lodash consumer is startCase; a local implementation
|
|
16
|
+
// beats shipping lodash for it. Splits camelCase and non-alphanumeric
|
|
17
|
+
// boundaries, capitalizes each word ("miserly-magenta" -> "Miserly Magenta").
|
|
18
|
+
export const startCase = (value: string): string =>
|
|
19
|
+
value
|
|
20
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
21
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
24
|
+
.join(" ");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
4
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Transformer } from "./mixin.ts";
|
|
8
|
+
|
|
9
|
+
// Lifecycle contract for the spicetify v3 modular loader (no /hooks).
|
|
10
|
+
export async function mixin(transformer: Transformer, ctx: { spotifyVersion: string }) {
|
|
11
|
+
return (await import("./mixin.js")).default(transformer, ctx);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The loader runs module lifecycles sequentially in dependency order. Holding
|
|
15
|
+
// stdlib's preload until its webpack analysis settles prevents every later UI
|
|
16
|
+
// module from observing half-populated React/component exports.
|
|
17
|
+
export async function preload() {
|
|
18
|
+
const { waitForWebpackCapture } = await import("./src/webpack/index.js");
|
|
19
|
+
await waitForWebpackCapture();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function load(ctx: { spotifyVersion: string }) {
|
|
23
|
+
return (await import("./load.js")).default(ctx);
|
|
24
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { React } from "../src/expose/React.ts";
|
|
7
|
+
import { Locale } from "../src/webpack/misc.ts";
|
|
8
|
+
|
|
9
|
+
import RootRegistry from "../src/registers/root.ts";
|
|
10
|
+
const [RootChildrenRegistry] = RootRegistry;
|
|
11
|
+
import { createIconComponent } from "../src/createIconComponent.tsx";
|
|
12
|
+
|
|
13
|
+
let ref: React.ReactElement | undefined = undefined;
|
|
14
|
+
|
|
15
|
+
export function display({ title, content, isLarge }: { title: string; content: React.ReactElement; isLarge: boolean }) {
|
|
16
|
+
hide();
|
|
17
|
+
|
|
18
|
+
RootChildrenRegistry.add(
|
|
19
|
+
(ref = (
|
|
20
|
+
<PopupModal contentLabel={title} children={content} isEmbedWidgetGeneratorOrTrackCreditsModal={isLarge} />
|
|
21
|
+
)),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hide() {
|
|
26
|
+
if (ref) {
|
|
27
|
+
RootChildrenRegistry.delete(ref);
|
|
28
|
+
ref = undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PopupModalProps {
|
|
33
|
+
contentLabel: string;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
isEmbedWidgetGeneratorOrTrackCreditsModal: boolean;
|
|
36
|
+
}
|
|
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
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Anchored popover: positions content in a floating panel under a trigger
|
|
7
|
+
// element (topbar buttons, playbar buttons, ...) and owns dismissal —
|
|
8
|
+
// click-outside, Escape, and reposition on resize. Content can be a React
|
|
9
|
+
// node (rendered with the client React) or a plain HTMLElement, so vanilla
|
|
10
|
+
// modules can use it without touching React.
|
|
11
|
+
|
|
12
|
+
import { React, ReactDOM } from "../src/expose/React.ts";
|
|
13
|
+
|
|
14
|
+
export interface PopoverHandle {
|
|
15
|
+
close: () => void;
|
|
16
|
+
host: HTMLElement;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PopoverOptions {
|
|
20
|
+
anchor: HTMLElement;
|
|
21
|
+
content: HTMLElement | React.ReactNode;
|
|
22
|
+
onClose?: () => void;
|
|
23
|
+
gap?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOptions): PopoverHandle {
|
|
27
|
+
const host = document.createElement("div");
|
|
28
|
+
host.className = "spicetify-popover";
|
|
29
|
+
Object.assign(host.style, {
|
|
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
|
+
});
|
|
39
|
+
|
|
40
|
+
const place = () => {
|
|
41
|
+
const rect = anchor.getBoundingClientRect();
|
|
42
|
+
host.style.top = `${Math.round(Math.min(rect.bottom + gap, window.innerHeight - 64))}px`;
|
|
43
|
+
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - host.offsetWidth - 8));
|
|
44
|
+
host.style.left = `${Math.round(left)}px`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let root: { unmount: () => void } | undefined;
|
|
48
|
+
if (content instanceof HTMLElement) {
|
|
49
|
+
host.appendChild(content);
|
|
50
|
+
} else {
|
|
51
|
+
const createRoot = (ReactDOM as any).createRoot;
|
|
52
|
+
root = createRoot(host);
|
|
53
|
+
(root as any).render(React.createElement(React.Fragment, null, content as any));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let closed = false;
|
|
57
|
+
const close = () => {
|
|
58
|
+
if (closed) return;
|
|
59
|
+
closed = true;
|
|
60
|
+
window.removeEventListener("resize", place);
|
|
61
|
+
document.removeEventListener("mousedown", onDocDown, true);
|
|
62
|
+
document.removeEventListener("keydown", onKey, true);
|
|
63
|
+
root?.unmount();
|
|
64
|
+
host.remove();
|
|
65
|
+
onClose?.();
|
|
66
|
+
};
|
|
67
|
+
const onDocDown = (e: MouseEvent) => {
|
|
68
|
+
const target = e.target as Node;
|
|
69
|
+
if (!host.contains(target) && !anchor.contains(target)) close();
|
|
70
|
+
};
|
|
71
|
+
const onKey = (e: KeyboardEvent) => {
|
|
72
|
+
if (e.key === "Escape") close();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
document.body.appendChild(host);
|
|
76
|
+
place();
|
|
77
|
+
// A second pass once layout has produced the real width, for clamping.
|
|
78
|
+
requestAnimationFrame(place);
|
|
79
|
+
window.addEventListener("resize", place);
|
|
80
|
+
document.addEventListener("mousedown", onDocDown, true);
|
|
81
|
+
document.addEventListener("keydown", onKey, true);
|
|
82
|
+
|
|
83
|
+
return { close, host };
|
|
84
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The single source of truth for the module chrome-class contract. Both
|
|
8
|
+
* the vanilla kit (ui.ts) and the React primitives (primitives.tsx) apply these,
|
|
9
|
+
* so the two tiers can never drift on which class means which primitive.
|
|
10
|
+
* The classes themselves live in stdlib's stylesheet (index.scss).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ButtonVariant = "primary" | "secondary" | "danger";
|
|
14
|
+
export type BadgeTone = "neutral" | "ok" | "bad";
|
|
15
|
+
|
|
16
|
+
export const buttonClass = (variant: ButtonVariant = "primary"): string =>
|
|
17
|
+
variant === "primary" ? "spicetify-button" : `spicetify-button spicetify-button--${variant}`;
|
|
18
|
+
|
|
19
|
+
export const badgeClass = (tone: BadgeTone = "neutral"): string =>
|
|
20
|
+
tone === "neutral" ? "spicetify-badge" : `spicetify-badge spicetify-badge--${tone}`;
|
|
21
|
+
|
|
22
|
+
export const chipClass = (active: boolean): string =>
|
|
23
|
+
active ? "spicetify-chip spicetify-chip--active" : "spicetify-chip";
|
|
24
|
+
|
|
25
|
+
export const ICON_BUTTON_CLASS = "spicetify-button-circle";
|
|
26
|
+
// A row in one of Spotify's own context menus. Unlike the spicetify-*
|
|
27
|
+
// classes above (ours, styled in index.scss), this is Spotify's stable
|
|
28
|
+
// semantic class — the kit owns it in this one place so module code never
|
|
29
|
+
// has to name a client class to get a native-looking menu item.
|
|
30
|
+
export const MENU_ITEM_CLASS = "main-contextMenu-menuItemButton";
|
|
31
|
+
export const SELECT_CLASS = "spicetify-select";
|
|
32
|
+
export const SEARCHBAR_CLASS = "spicetify-searchbar";
|
|
33
|
+
export const CARD_CLASS = "spicetify-card";
|
|
34
|
+
export const SCRIM_CLASS = "spicetify-scrim";
|
|
35
|
+
export const DIALOG_CLASS = "spicetify-dialog";
|
|
36
|
+
export const DIALOG_HEADER_CLASS = "spicetify-dialog-header";
|
|
37
|
+
export const DIALOG_BODY_CLASS = "spicetify-dialog-body";
|
|
38
|
+
|
|
39
|
+
// The client's own settings chrome. These names exist because the CLI's
|
|
40
|
+
// css-map recreates them, so a module row is styled by Spotify's own CSS
|
|
41
|
+
// instead of approximating it.
|
|
42
|
+
export const SETTINGS_SECTION_CLASS = "x-settings-section";
|
|
43
|
+
export const SETTINGS_ROW_CLASS = "x-settings-row";
|
|
44
|
+
export const SETTINGS_ROW_LABEL_CLASS = "x-settings-firstColumn";
|
|
45
|
+
export const SETTINGS_ROW_CONTROL_CLASS = "x-settings-secondColumn";
|
|
46
|
+
export const TOGGLE_CLASSES = {
|
|
47
|
+
wrapper: "x-toggle-wrapper",
|
|
48
|
+
input: "x-toggle-input",
|
|
49
|
+
indicatorWrapper: "x-toggle-indicatorWrapper",
|
|
50
|
+
indicator: "x-toggle-indicator",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/** @deprecated Use TOGGLE_CLASSES with the native switch structure. */
|
|
54
|
+
export const TOGGLE_CLASS = "spicetify-toggle";
|
|
55
|
+
|
|
56
|
+
export function activateToggleOnKeyDown(event: {
|
|
57
|
+
key: string;
|
|
58
|
+
currentTarget: { click: () => void };
|
|
59
|
+
preventDefault: () => void;
|
|
60
|
+
stopPropagation: () => void;
|
|
61
|
+
}): void {
|
|
62
|
+
if (event.key !== " " && event.key !== "Enter") return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
event.currentTarget.click();
|
|
66
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A vanilla, React-free component kit for module UIs. Every primitive is
|
|
8
|
+
* a plain function returning a typed HTMLElement, built on the `h()`
|
|
9
|
+
* hyperscript, and applies the shared `.spicetify-*` chrome classes from
|
|
10
|
+
* stdlib's stylesheet. Because it touches nothing but `document`, it is
|
|
11
|
+
* safe on standalone module surfaces (anchors, body dialogs, popovers)
|
|
12
|
+
* where the client React instance and client webpack components are
|
|
13
|
+
* unavailable — the same tier-2 role the chrome classes already fill,
|
|
14
|
+
* now as named, composable primitives.
|
|
15
|
+
*
|
|
16
|
+
* Inside a client React tree, prefer the real client components via
|
|
17
|
+
* stdlib's ComponentLibrary; this kit is for module-owned DOM.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
badgeClass,
|
|
22
|
+
buttonClass,
|
|
23
|
+
type BadgeTone,
|
|
24
|
+
type ButtonVariant,
|
|
25
|
+
CARD_CLASS,
|
|
26
|
+
chipClass,
|
|
27
|
+
DIALOG_BODY_CLASS,
|
|
28
|
+
DIALOG_CLASS,
|
|
29
|
+
DIALOG_HEADER_CLASS,
|
|
30
|
+
ICON_BUTTON_CLASS,
|
|
31
|
+
SCRIM_CLASS,
|
|
32
|
+
SEARCHBAR_CLASS,
|
|
33
|
+
SELECT_CLASS,
|
|
34
|
+
} from "./primitives-classes.ts";
|
|
35
|
+
|
|
36
|
+
export type { BadgeTone, ButtonVariant } from "./primitives-classes.ts";
|
|
37
|
+
|
|
38
|
+
// Typed event handlers: only real DOM events are accepted, so a
|
|
39
|
+
// misspelled `onClik` is a type error rather than a silently-attached
|
|
40
|
+
// listener for a "clik" event.
|
|
41
|
+
type EventHandlers = {
|
|
42
|
+
[E in keyof GlobalEventHandlersEventMap as `on${Capitalize<E>}`]?: (event: GlobalEventHandlersEventMap[E]) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Props for h(): className/textContent/style, dataset, aria-* attributes,
|
|
46
|
+
// on<Event> listeners, and any writable property of the element itself
|
|
47
|
+
// (value, disabled, placeholder, type, ...), so consumers never cast.
|
|
48
|
+
export type Props<K extends keyof HTMLElementTagNameMap> = Partial<
|
|
49
|
+
Omit<HTMLElementTagNameMap[K], `on${string}` | "style" | "children" | "dataset">
|
|
50
|
+
> &
|
|
51
|
+
EventHandlers & {
|
|
52
|
+
className?: string;
|
|
53
|
+
dataset?: Record<string, string>;
|
|
54
|
+
style?: Partial<CSSStyleDeclaration>;
|
|
55
|
+
[aria: `aria-${string}`]: string | undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type Child = string | Node | false | null | undefined | Child[];
|
|
59
|
+
|
|
60
|
+
const appendChild = (parent: HTMLElement, child: Child): void => {
|
|
61
|
+
if (child === false || child === null || child === undefined) return;
|
|
62
|
+
if (Array.isArray(child)) {
|
|
63
|
+
for (const c of child) appendChild(parent, c);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
parent.append(child);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// h - typed hyperscript. The generalized, exported successor to the
|
|
70
|
+
// store's local el(); assigns known element properties directly (so no
|
|
71
|
+
// `as HTMLXElement` casts), maps on<Event> keys to addEventListener, and
|
|
72
|
+
// flattens truthy children.
|
|
73
|
+
export function h<K extends keyof HTMLElementTagNameMap>(
|
|
74
|
+
tag: K,
|
|
75
|
+
props?: Props<K> | null,
|
|
76
|
+
...children: Child[]
|
|
77
|
+
): HTMLElementTagNameMap[K] {
|
|
78
|
+
const node = document.createElement(tag);
|
|
79
|
+
if (props) {
|
|
80
|
+
for (const [key, value] of Object.entries(props)) {
|
|
81
|
+
if (value === undefined) continue;
|
|
82
|
+
if (key === "dataset") {
|
|
83
|
+
Object.assign(node.dataset, value);
|
|
84
|
+
} else if (key === "style") {
|
|
85
|
+
Object.assign(node.style, value);
|
|
86
|
+
} else if (key.startsWith("aria-")) {
|
|
87
|
+
node.setAttribute(key, value as string);
|
|
88
|
+
} else if (/^on[A-Z]/.test(key) && typeof value === "function") {
|
|
89
|
+
node.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
|
|
90
|
+
} else {
|
|
91
|
+
(node as Record<string, unknown>)[key] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const child of children) appendChild(node, child);
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------- buttons ----------
|
|
100
|
+
|
|
101
|
+
export function Button(props: {
|
|
102
|
+
label: string;
|
|
103
|
+
variant?: ButtonVariant;
|
|
104
|
+
onClick: () => void;
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
icon?: Node;
|
|
107
|
+
}): HTMLButtonElement {
|
|
108
|
+
return h(
|
|
109
|
+
"button",
|
|
110
|
+
{ type: "button", className: buttonClass(props.variant), disabled: props.disabled, onClick: props.onClick },
|
|
111
|
+
props.icon,
|
|
112
|
+
props.label,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The circular icon button Spotify uses for modal close (Credits, etc).
|
|
117
|
+
export function IconButton(props: { glyph: string; ariaLabel: string; onClick: () => void }): HTMLButtonElement {
|
|
118
|
+
return h("button", {
|
|
119
|
+
type: "button",
|
|
120
|
+
className: ICON_BUTTON_CLASS,
|
|
121
|
+
"aria-label": props.ariaLabel,
|
|
122
|
+
textContent: props.glyph,
|
|
123
|
+
onClick: props.onClick,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- inputs ----------
|
|
128
|
+
|
|
129
|
+
export function Select<T extends string>(props: {
|
|
130
|
+
options: ReadonlyArray<{ value: T; label: string }>;
|
|
131
|
+
value: T;
|
|
132
|
+
onChange: (value: T) => void;
|
|
133
|
+
}): HTMLSelectElement {
|
|
134
|
+
const select = h("select", {
|
|
135
|
+
className: SELECT_CLASS,
|
|
136
|
+
onChange: () => props.onChange(select.value as T),
|
|
137
|
+
});
|
|
138
|
+
for (const option of props.options) {
|
|
139
|
+
select.append(
|
|
140
|
+
h("option", { value: option.value, textContent: option.label, selected: option.value === props.value }),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
select.value = props.value;
|
|
144
|
+
return select;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function TextInput(props: {
|
|
148
|
+
placeholder?: string;
|
|
149
|
+
value?: string;
|
|
150
|
+
onInput?: (value: string) => void;
|
|
151
|
+
disabled?: boolean;
|
|
152
|
+
}): HTMLInputElement {
|
|
153
|
+
const input = h("input", {
|
|
154
|
+
type: "text",
|
|
155
|
+
className: SEARCHBAR_CLASS,
|
|
156
|
+
placeholder: props.placeholder,
|
|
157
|
+
value: props.value,
|
|
158
|
+
disabled: props.disabled,
|
|
159
|
+
onInput: props.onInput ? () => props.onInput!(input.value) : undefined,
|
|
160
|
+
});
|
|
161
|
+
return input;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function Textarea(props: {
|
|
165
|
+
placeholder?: string;
|
|
166
|
+
value?: string;
|
|
167
|
+
onInput?: (value: string) => void;
|
|
168
|
+
}): HTMLTextAreaElement {
|
|
169
|
+
const textarea = h("textarea", {
|
|
170
|
+
placeholder: props.placeholder,
|
|
171
|
+
value: props.value,
|
|
172
|
+
onInput: props.onInput ? () => props.onInput!(textarea.value) : undefined,
|
|
173
|
+
});
|
|
174
|
+
return textarea;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------- badges, chips, cards ----------
|
|
178
|
+
|
|
179
|
+
export function Badge(props: { text: string; tone?: BadgeTone }): HTMLSpanElement {
|
|
180
|
+
return h("span", { className: badgeClass(props.tone), textContent: props.text });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function Chip(props: { label: string; active: boolean; onClick: () => void }): HTMLButtonElement {
|
|
184
|
+
return h("button", {
|
|
185
|
+
type: "button",
|
|
186
|
+
className: chipClass(props.active),
|
|
187
|
+
textContent: props.label,
|
|
188
|
+
onClick: props.onClick,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A plain elevated container; slot layout is the consumer's own concern.
|
|
193
|
+
export function Card(props: { children: Child }): HTMLElement {
|
|
194
|
+
return h("article", { className: CARD_CLASS }, props.children);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------- two-step confirm ----------
|
|
198
|
+
|
|
199
|
+
// ConfirmButton encapsulates the arm-then-confirm pattern: the first
|
|
200
|
+
// click swaps the label and arms a revert timer; a second click within
|
|
201
|
+
// the window confirms. Timers are injectable so the window is
|
|
202
|
+
// deterministic under test.
|
|
203
|
+
export function ConfirmButton(props: {
|
|
204
|
+
label: string;
|
|
205
|
+
confirmLabel: string;
|
|
206
|
+
onConfirm: () => void;
|
|
207
|
+
variant?: ButtonVariant;
|
|
208
|
+
windowMs?: number;
|
|
209
|
+
setTimer?: (fn: () => void, ms: number) => number;
|
|
210
|
+
clearTimer?: (handle: number) => void;
|
|
211
|
+
}): HTMLButtonElement {
|
|
212
|
+
const setTimer = props.setTimer ?? ((fn, ms) => setTimeout(fn, ms) as unknown as number);
|
|
213
|
+
const clearTimer = props.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
214
|
+
let armed: number | null = null;
|
|
215
|
+
const disarm = () => {
|
|
216
|
+
if (armed !== null) clearTimer(armed);
|
|
217
|
+
armed = null;
|
|
218
|
+
button.textContent = props.label;
|
|
219
|
+
};
|
|
220
|
+
const button = h("button", {
|
|
221
|
+
type: "button",
|
|
222
|
+
className: buttonClass(props.variant),
|
|
223
|
+
textContent: props.label,
|
|
224
|
+
onClick: () => {
|
|
225
|
+
if (armed !== null) {
|
|
226
|
+
disarm();
|
|
227
|
+
props.onConfirm();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
button.textContent = props.confirmLabel;
|
|
231
|
+
armed = setTimer(disarm, props.windowMs ?? 4000);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
return button;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------- dialog ----------
|
|
238
|
+
|
|
239
|
+
export interface DialogHandle {
|
|
240
|
+
body: HTMLElement;
|
|
241
|
+
close: () => void;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// openDialog mounts a scrim + dialog (with a title and circular close),
|
|
245
|
+
// appends it to document.body, and returns the body plus a close(). The
|
|
246
|
+
// backdrop, close button, and programmatic close() all run one teardown,
|
|
247
|
+
// so onClose fires exactly once on any dismissal path (consumers use it
|
|
248
|
+
// to drop their own bookkeeping, e.g. a dispose registry).
|
|
249
|
+
export function openDialog(props: { title: string; children: Child; onClose?: () => void }): DialogHandle {
|
|
250
|
+
const body = h("div", { className: DIALOG_BODY_CLASS }, props.children);
|
|
251
|
+
let closed = false;
|
|
252
|
+
const close = () => {
|
|
253
|
+
if (closed) return;
|
|
254
|
+
closed = true;
|
|
255
|
+
scrim.remove();
|
|
256
|
+
props.onClose?.();
|
|
257
|
+
};
|
|
258
|
+
const header = h(
|
|
259
|
+
"div",
|
|
260
|
+
{ className: DIALOG_HEADER_CLASS },
|
|
261
|
+
h("h2", { textContent: props.title }),
|
|
262
|
+
IconButton({ glyph: "×", ariaLabel: "Close", onClick: close }),
|
|
263
|
+
);
|
|
264
|
+
const dialog = h("div", { className: DIALOG_CLASS }, header, body);
|
|
265
|
+
const scrim = h(
|
|
266
|
+
"div",
|
|
267
|
+
{
|
|
268
|
+
className: SCRIM_CLASS,
|
|
269
|
+
onClick: (e) => {
|
|
270
|
+
if (e.target === scrim) close();
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
dialog,
|
|
274
|
+
);
|
|
275
|
+
document.body.append(scrim);
|
|
276
|
+
return { body, close };
|
|
277
|
+
}
|