@spicetify/kit 0.1.0 → 0.2.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.
@@ -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
- let ref: React.ReactElement | undefined = undefined;
13
+ export interface ModalOptions {
14
+ title: string;
15
+ content: React.ReactNode;
16
+ isLarge?: boolean;
17
+ }
14
18
 
15
- export function display({ title, content, isLarge }: { title: string; content: React.ReactElement; isLarge: boolean }) {
16
- hide();
19
+ let mountedModal: React.ReactElement | undefined;
17
20
 
18
- RootChildrenRegistry.add(
19
- (ref = (
20
- <PopupModal contentLabel={title} children={content} isEmbedWidgetGeneratorOrTrackCreditsModal={isLarge} />
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 (ref) {
27
- RootChildrenRegistry.delete(ref);
28
- ref = undefined;
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
- };
@@ -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
- 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
- });
30
+ host.style.visibility = "hidden";
39
31
 
40
32
  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`;
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: { unmount: () => void } | undefined;
46
+ let root: ReturnType<typeof ReactDOM.createRoot> | undefined;
48
47
  if (content instanceof HTMLElement) {
49
48
  host.appendChild(content);
50
49
  } 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));
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 === "Escape") close();
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
- export function openDialog(props: { title: string; children: Child; onClose?: () => void }): DialogHandle {
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("div", { className: DIALOG_CLASS }, header, body);
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
  }