universal-modal-monorepo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/AUTHORS +5 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LICENSE +21 -0
  4. package/NOTICE +6 -0
  5. package/README.md +239 -0
  6. package/SECURITY.md +21 -0
  7. package/docs/ACCESSIBILITY.md +43 -0
  8. package/docs/API.md +56 -0
  9. package/docs/THEMING.md +65 -0
  10. package/examples/angular/app.component.html +137 -0
  11. package/examples/angular/app.component.ts +53 -0
  12. package/examples/angular/index.html +99 -0
  13. package/examples/angular/main.js +121 -0
  14. package/examples/angular/styles.css +25 -0
  15. package/examples/index.html +53 -0
  16. package/examples/react/App.tsx +242 -0
  17. package/examples/react/index.html +26 -0
  18. package/examples/react/main.js +144 -0
  19. package/examples/react/styles.css +1 -0
  20. package/examples/shared/gallery.css +177 -0
  21. package/examples/vanilla/index.html +89 -0
  22. package/examples/vanilla/main.js +174 -0
  23. package/examples/vanilla/styles.css +1 -0
  24. package/examples/vue/App.vue +190 -0
  25. package/examples/vue/index.html +23 -0
  26. package/examples/vue/main.js +174 -0
  27. package/examples/vue/styles.css +1 -0
  28. package/examples/web-component/index.html +190 -0
  29. package/examples/web-component/main.js +58 -0
  30. package/examples/web-component/styles.css +11 -0
  31. package/package.json +36 -0
  32. package/packages/universal-modal/AUTHORS +5 -0
  33. package/packages/universal-modal/CHANGELOG.md +10 -0
  34. package/packages/universal-modal/LICENSE +21 -0
  35. package/packages/universal-modal/NOTICE +6 -0
  36. package/packages/universal-modal/README.md +29 -0
  37. package/packages/universal-modal/package.json +83 -0
  38. package/packages/universal-modal/scripts/copy-styles.mjs +11 -0
  39. package/packages/universal-modal/src/core/constants.ts +50 -0
  40. package/packages/universal-modal/src/core/event-emitter.ts +46 -0
  41. package/packages/universal-modal/src/core/focus-trap.ts +101 -0
  42. package/packages/universal-modal/src/core/index.ts +39 -0
  43. package/packages/universal-modal/src/core/modal-engine.ts +410 -0
  44. package/packages/universal-modal/src/core/modal-stack.ts +68 -0
  45. package/packages/universal-modal/src/core/scroll-lock.ts +78 -0
  46. package/packages/universal-modal/src/core/theme.ts +50 -0
  47. package/packages/universal-modal/src/core/types.ts +196 -0
  48. package/packages/universal-modal/src/core/utils.ts +114 -0
  49. package/packages/universal-modal/src/dom/index.ts +1 -0
  50. package/packages/universal-modal/src/dom/modal-renderer.ts +191 -0
  51. package/packages/universal-modal/src/index.ts +2 -0
  52. package/packages/universal-modal/src/styles/modal.css +368 -0
  53. package/packages/universal-modal/tsconfig.json +24 -0
  54. package/packages/universal-modal/tsup.config.ts +12 -0
  55. package/packages/universal-modal-angular/AUTHORS +5 -0
  56. package/packages/universal-modal-angular/CHANGELOG.md +9 -0
  57. package/packages/universal-modal-angular/LICENSE +21 -0
  58. package/packages/universal-modal-angular/NOTICE +6 -0
  59. package/packages/universal-modal-angular/README.md +12 -0
  60. package/packages/universal-modal-angular/package.json +85 -0
  61. package/packages/universal-modal-angular/src/index.ts +10 -0
  62. package/packages/universal-modal-angular/src/universal-modal.component.ts +275 -0
  63. package/packages/universal-modal-angular/tsconfig.json +20 -0
  64. package/packages/universal-modal-angular/tsup.config.ts +10 -0
  65. package/packages/universal-modal-react/AUTHORS +5 -0
  66. package/packages/universal-modal-react/CHANGELOG.md +9 -0
  67. package/packages/universal-modal-react/LICENSE +21 -0
  68. package/packages/universal-modal-react/NOTICE +6 -0
  69. package/packages/universal-modal-react/README.md +12 -0
  70. package/packages/universal-modal-react/package.json +87 -0
  71. package/packages/universal-modal-react/src/UniversalModal.tsx +340 -0
  72. package/packages/universal-modal-react/src/index.ts +10 -0
  73. package/packages/universal-modal-react/tsconfig.json +19 -0
  74. package/packages/universal-modal-react/tsup.config.ts +13 -0
  75. package/packages/universal-modal-vanilla/AUTHORS +5 -0
  76. package/packages/universal-modal-vanilla/CHANGELOG.md +9 -0
  77. package/packages/universal-modal-vanilla/LICENSE +21 -0
  78. package/packages/universal-modal-vanilla/NOTICE +6 -0
  79. package/packages/universal-modal-vanilla/README.md +12 -0
  80. package/packages/universal-modal-vanilla/package.json +79 -0
  81. package/packages/universal-modal-vanilla/src/create-modal.ts +125 -0
  82. package/packages/universal-modal-vanilla/src/index.ts +14 -0
  83. package/packages/universal-modal-vanilla/tsconfig.json +18 -0
  84. package/packages/universal-modal-vanilla/tsup.config.ts +10 -0
  85. package/packages/universal-modal-vue/AUTHORS +5 -0
  86. package/packages/universal-modal-vue/CHANGELOG.md +9 -0
  87. package/packages/universal-modal-vue/LICENSE +21 -0
  88. package/packages/universal-modal-vue/NOTICE +6 -0
  89. package/packages/universal-modal-vue/README.md +12 -0
  90. package/packages/universal-modal-vue/package.json +83 -0
  91. package/packages/universal-modal-vue/src/UniversalModal.ts +342 -0
  92. package/packages/universal-modal-vue/src/index.ts +10 -0
  93. package/packages/universal-modal-vue/tsconfig.json +18 -0
  94. package/packages/universal-modal-vue/tsup.config.ts +10 -0
  95. package/packages/universal-modal-web-component/AUTHORS +5 -0
  96. package/packages/universal-modal-web-component/CHANGELOG.md +9 -0
  97. package/packages/universal-modal-web-component/LICENSE +21 -0
  98. package/packages/universal-modal-web-component/NOTICE +6 -0
  99. package/packages/universal-modal-web-component/README.md +12 -0
  100. package/packages/universal-modal-web-component/package.json +83 -0
  101. package/packages/universal-modal-web-component/src/index.ts +11 -0
  102. package/packages/universal-modal-web-component/src/universal-modal-element.ts +424 -0
  103. package/packages/universal-modal-web-component/tsconfig.json +18 -0
  104. package/packages/universal-modal-web-component/tsup.config.ts +10 -0
  105. package/scripts/copy-styles.mjs +11 -0
  106. package/scripts/serve-examples.mjs +48 -0
@@ -0,0 +1,410 @@
1
+ import { CSS_CLASSES } from './constants';
2
+ import { EventEmitter } from './event-emitter';
3
+ import { createFocusTrap, restoreFocus, type FocusTrap } from './focus-trap';
4
+ import { modalStack } from './modal-stack';
5
+ import { lockBodyScroll, unlockBodyScroll } from './scroll-lock';
6
+ import type {
7
+ CloseReason,
8
+ ModalEventMap,
9
+ ModalHostElements,
10
+ ModalOptions,
11
+ ModalState,
12
+ ResolvedModalOptions,
13
+ } from './types';
14
+ import { applyModalTheme } from './theme';
15
+ import {
16
+ getReturnFocusElement,
17
+ prefersReducedMotion,
18
+ resolveInitialFocus,
19
+ resolveOptions,
20
+ runLifecycle,
21
+ } from './utils';
22
+
23
+ export interface ModalEngineConfig extends ModalOptions {
24
+ /** When true, engine manages focus trap + ESC on the attached host. */
25
+ manageDom?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Framework-agnostic modal controller.
30
+ * Owns state, lifecycle, stacking, scroll lock, and optional DOM host wiring.
31
+ */
32
+ export class ModalEngine {
33
+ private options: ResolvedModalOptions;
34
+ private readonly emitter = new EventEmitter();
35
+ private opened = false;
36
+ private loading = false;
37
+ private transitioning = false;
38
+ private zIndex = 0;
39
+ private host: ModalHostElements | null = null;
40
+ private focusTrap: FocusTrap | null = null;
41
+ private returnFocusEl: HTMLElement | null = null;
42
+ private manageDom: boolean;
43
+ private boundKeyDown = (event: KeyboardEvent): void => {
44
+ this.handleKeyDown(event);
45
+ };
46
+ private stacked = false;
47
+
48
+ constructor(config: ModalEngineConfig = {}) {
49
+ const { manageDom = true, ...options } = config;
50
+ this.manageDom = manageDom;
51
+ this.options = resolveOptions(options);
52
+ this.zIndex = this.options.zIndexBase;
53
+ }
54
+
55
+ get id(): string {
56
+ return this.options.id;
57
+ }
58
+
59
+ get isOpen(): boolean {
60
+ return this.opened;
61
+ }
62
+
63
+ get isLoading(): boolean {
64
+ return this.loading;
65
+ }
66
+
67
+ getOptions(): Readonly<ResolvedModalOptions> {
68
+ return this.options;
69
+ }
70
+
71
+ getState(): ModalState {
72
+ return {
73
+ id: this.options.id,
74
+ open: this.opened,
75
+ loading: this.loading,
76
+ size: this.options.size,
77
+ placement: this.options.placement,
78
+ persistent: this.options.persistent,
79
+ closeOnEsc: this.options.closeOnEsc,
80
+ closeOnBackdrop: this.options.closeOnBackdrop,
81
+ showCloseIcon: this.options.showCloseIcon,
82
+ role: this.options.role,
83
+ zIndex: this.zIndex,
84
+ title: this.options.title,
85
+ description: this.options.description,
86
+ confirmText: this.options.confirmText,
87
+ cancelText: this.options.cancelText,
88
+ hideActions: this.options.hideActions,
89
+ };
90
+ }
91
+
92
+ updateOptions(partial: Partial<ModalOptions>): void {
93
+ this.options = resolveOptions({
94
+ ...this.options,
95
+ ...partial,
96
+ id: this.options.id,
97
+ });
98
+ if (this.host) {
99
+ this.applyHostAttributes();
100
+ }
101
+ }
102
+
103
+ on<K extends keyof ModalEventMap>(
104
+ event: K,
105
+ listener: (payload: ModalEventMap[K]) => void,
106
+ ): () => void {
107
+ return this.emitter.on(event, listener);
108
+ }
109
+
110
+ off<K extends keyof ModalEventMap>(
111
+ event: K,
112
+ listener: (payload: ModalEventMap[K]) => void,
113
+ ): void {
114
+ this.emitter.off(event, listener);
115
+ }
116
+
117
+ /**
118
+ * Bind rendered DOM nodes so the engine can trap focus, wire ESC, and sync ARIA.
119
+ */
120
+ attach(host: ModalHostElements): void {
121
+ this.detach();
122
+ this.host = host;
123
+ this.focusTrap = createFocusTrap(host.dialog);
124
+ this.applyHostAttributes();
125
+
126
+ if (this.manageDom) {
127
+ host.backdrop.addEventListener('click', this.handleBackdropClick);
128
+ host.closeButton?.addEventListener('click', this.handleCloseIconClick);
129
+ host.confirmButton?.addEventListener('click', this.handleConfirmClick);
130
+ host.cancelButton?.addEventListener('click', this.handleCancelClick);
131
+ }
132
+ }
133
+
134
+ detach(): void {
135
+ if (!this.host) return;
136
+
137
+ if (this.manageDom) {
138
+ this.host.backdrop.removeEventListener('click', this.handleBackdropClick);
139
+ this.host.closeButton?.removeEventListener('click', this.handleCloseIconClick);
140
+ this.host.confirmButton?.removeEventListener('click', this.handleConfirmClick);
141
+ this.host.cancelButton?.removeEventListener('click', this.handleCancelClick);
142
+ }
143
+
144
+ this.focusTrap?.deactivate();
145
+ this.focusTrap = null;
146
+ this.host = null;
147
+ }
148
+
149
+ async open(): Promise<boolean> {
150
+ if (this.opened || this.transitioning) return false;
151
+
152
+ this.emitter.emit('beforeOpen');
153
+ const allowed = await runLifecycle(this.options.beforeOpen);
154
+ if (!allowed) return false;
155
+
156
+ this.transitioning = true;
157
+ this.returnFocusEl = getReturnFocusElement(
158
+ this.options.returnFocus,
159
+ document.activeElement,
160
+ );
161
+
162
+ this.opened = true;
163
+ this.emitter.emit('openChange', true);
164
+
165
+ this.zIndex = modalStack.push({
166
+ id: this.id,
167
+ zIndexBase: this.options.zIndexBase,
168
+ activate: () => this.activateLayer(),
169
+ deactivate: () => this.deactivateLayer(),
170
+ });
171
+ this.stacked = true;
172
+
173
+ lockBodyScroll();
174
+ this.applyHostOpenState(true);
175
+
176
+ if (this.host && this.manageDom) {
177
+ const initial = resolveInitialFocus(this.options.initialFocus, this.host.dialog);
178
+ this.focusTrap?.activate(initial);
179
+ }
180
+
181
+ const duration = prefersReducedMotion() ? 0 : this.options.animationDuration;
182
+ await wait(duration);
183
+
184
+ this.transitioning = false;
185
+ this.options.afterOpen?.();
186
+ this.emitter.emit('afterOpen');
187
+ return true;
188
+ }
189
+
190
+ async close(reason: CloseReason = 'programmatic'): Promise<boolean> {
191
+ if (!this.opened || this.transitioning) return false;
192
+ if (this.loading && reason !== 'programmatic') return false;
193
+
194
+ this.emitter.emit('beforeClose', reason);
195
+ const allowed = await runLifecycle(this.options.beforeClose, reason);
196
+ if (!allowed) return false;
197
+
198
+ this.transitioning = true;
199
+ this.applyHostOpenState(false, true);
200
+
201
+ const duration = prefersReducedMotion() ? 0 : this.options.animationDuration;
202
+ await wait(duration);
203
+
204
+ this.opened = false;
205
+ this.emitter.emit('openChange', false);
206
+ this.teardownLayer();
207
+ this.applyHostOpenState(false, false);
208
+
209
+ restoreFocus(this.returnFocusEl);
210
+ this.returnFocusEl = null;
211
+ this.transitioning = false;
212
+
213
+ this.options.afterClose?.(reason);
214
+ this.emitter.emit('afterClose', reason);
215
+ return true;
216
+ }
217
+
218
+ async confirm(): Promise<void> {
219
+ if (!this.opened || this.loading) return;
220
+ this.emitter.emit('confirm');
221
+
222
+ const handler = this.options.onConfirm;
223
+ if (!handler) {
224
+ await this.close('confirm');
225
+ return;
226
+ }
227
+
228
+ this.setLoading(true);
229
+ try {
230
+ await handler();
231
+ await this.close('confirm');
232
+ } catch (error) {
233
+ this.setLoading(false);
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ async cancel(): Promise<void> {
239
+ if (!this.opened || this.loading) return;
240
+ this.emitter.emit('cancel');
241
+
242
+ const handler = this.options.onCancel;
243
+ if (!handler) {
244
+ await this.close('cancel');
245
+ return;
246
+ }
247
+
248
+ this.setLoading(true);
249
+ try {
250
+ await handler();
251
+ await this.close('cancel');
252
+ } catch (error) {
253
+ this.setLoading(false);
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ setLoading(loading: boolean): void {
259
+ if (this.loading === loading) return;
260
+ this.loading = loading;
261
+ this.emitter.emit('loadingChange', loading);
262
+
263
+ if (this.host) {
264
+ this.host.root.classList.toggle(CSS_CLASSES.loading, loading);
265
+ if (this.host.confirmButton) {
266
+ this.host.confirmButton.disabled = loading;
267
+ this.host.confirmButton.setAttribute('aria-busy', String(loading));
268
+ }
269
+ if (this.host.cancelButton) {
270
+ this.host.cancelButton.disabled = loading;
271
+ }
272
+ if (this.host.closeButton) {
273
+ (this.host.closeButton as HTMLButtonElement).disabled = loading;
274
+ }
275
+ }
276
+ }
277
+
278
+ handleBackdropClick = (): void => {
279
+ if (!this.opened || !this.options.closeOnBackdrop || this.options.persistent) return;
280
+ if (!modalStack.isTop(this.id)) return;
281
+ void this.close('backdrop');
282
+ };
283
+
284
+ handleCloseIconClick = (): void => {
285
+ if (!this.opened || this.loading) return;
286
+ void this.close('close-icon');
287
+ };
288
+
289
+ handleConfirmClick = (): void => {
290
+ void this.confirm();
291
+ };
292
+
293
+ handleCancelClick = (): void => {
294
+ void this.cancel();
295
+ };
296
+
297
+ handleKeyDown(event: KeyboardEvent): void {
298
+ if (!this.opened || event.key !== 'Escape') return;
299
+ if (!modalStack.isTop(this.id)) return;
300
+ if (!this.options.closeOnEsc || this.options.persistent || this.loading) return;
301
+
302
+ event.preventDefault();
303
+ event.stopPropagation();
304
+ void this.close('esc');
305
+ }
306
+
307
+ destroy(): void {
308
+ if (this.opened) {
309
+ this.opened = false;
310
+ this.teardownLayer();
311
+ }
312
+ this.detach();
313
+ this.emitter.clear();
314
+ }
315
+
316
+ private activateLayer(): void {
317
+ if (!this.manageDom) return;
318
+ document.addEventListener('keydown', this.boundKeyDown, true);
319
+ if (this.host && this.opened) {
320
+ const initial = resolveInitialFocus(this.options.initialFocus, this.host.dialog);
321
+ this.focusTrap?.activate(initial);
322
+ }
323
+ }
324
+
325
+ private deactivateLayer(): void {
326
+ if (!this.manageDom) return;
327
+ document.removeEventListener('keydown', this.boundKeyDown, true);
328
+ this.focusTrap?.deactivate();
329
+ }
330
+
331
+ private teardownLayer(): void {
332
+ if (this.stacked) {
333
+ modalStack.remove(this.id);
334
+ this.stacked = false;
335
+ unlockBodyScroll();
336
+ }
337
+ document.removeEventListener('keydown', this.boundKeyDown, true);
338
+ this.focusTrap?.deactivate();
339
+ this.setLoading(false);
340
+ }
341
+
342
+ private applyHostAttributes(): void {
343
+ if (!this.host) return;
344
+ const { root, dialog, panel } = this.host;
345
+ const opts = this.options;
346
+
347
+ root.dataset.modalId = opts.id;
348
+ root.style.setProperty('--um-z-index', String(this.zIndex || opts.zIndexBase));
349
+ root.style.setProperty('--um-animation-duration', `${opts.animationDuration}ms`);
350
+ root.classList.add(CSS_CLASSES.root);
351
+ root.classList.toggle(CSS_CLASSES.loading, this.loading);
352
+ if (opts.className) {
353
+ for (const token of opts.className.split(/\s+/).filter(Boolean)) {
354
+ root.classList.add(token);
355
+ }
356
+ }
357
+ applyModalTheme(root, opts.theme);
358
+
359
+ panel.dataset.size = opts.size;
360
+ panel.dataset.placement = opts.placement;
361
+
362
+ dialog.setAttribute('role', opts.role);
363
+ dialog.setAttribute('aria-modal', 'true');
364
+ dialog.tabIndex = -1;
365
+
366
+ const titleId = `${opts.id}-title`;
367
+ const descId = `${opts.id}-description`;
368
+
369
+ if (opts.ariaLabelledBy) {
370
+ dialog.setAttribute('aria-labelledby', opts.ariaLabelledBy);
371
+ } else if (opts.title) {
372
+ dialog.setAttribute('aria-labelledby', titleId);
373
+ } else if (opts.ariaLabel) {
374
+ dialog.setAttribute('aria-label', opts.ariaLabel);
375
+ dialog.removeAttribute('aria-labelledby');
376
+ }
377
+
378
+ if (opts.ariaDescribedBy) {
379
+ dialog.setAttribute('aria-describedby', opts.ariaDescribedBy);
380
+ } else if (opts.description) {
381
+ dialog.setAttribute('aria-describedby', descId);
382
+ }
383
+
384
+ if (this.host.closeButton) {
385
+ this.host.closeButton.hidden = !opts.showCloseIcon;
386
+ this.host.closeButton.setAttribute('aria-label', 'Close dialog');
387
+ }
388
+ }
389
+
390
+ private applyHostOpenState(open: boolean, closing = false): void {
391
+ if (!this.host) return;
392
+ const { root } = this.host;
393
+ root.classList.toggle(CSS_CLASSES.open, open && !closing);
394
+ root.classList.toggle(CSS_CLASSES.closing, closing);
395
+ root.style.setProperty('--um-z-index', String(this.zIndex || this.options.zIndexBase));
396
+ root.setAttribute('aria-hidden', open ? 'false' : 'true');
397
+ if (!open && !closing) {
398
+ root.hidden = true;
399
+ } else {
400
+ root.hidden = false;
401
+ }
402
+ }
403
+ }
404
+
405
+ function wait(ms: number): Promise<void> {
406
+ if (ms <= 0) return Promise.resolve();
407
+ return new Promise((resolve) => {
408
+ window.setTimeout(resolve, ms);
409
+ });
410
+ }
@@ -0,0 +1,68 @@
1
+ import { DEFAULT_Z_INDEX_BASE, Z_INDEX_LAYER_STEP } from './constants';
2
+
3
+ export interface StackEntry {
4
+ id: string;
5
+ zIndexBase: number;
6
+ activate: () => void;
7
+ deactivate: () => void;
8
+ }
9
+
10
+ /**
11
+ * Global stack coordinating nested/stacked modals:
12
+ * - assigns increasing z-index layers
13
+ * - only the top modal receives ESC / focus-trap activation
14
+ */
15
+ class ModalStackManager {
16
+ private readonly stack: StackEntry[] = [];
17
+
18
+ get size(): number {
19
+ return this.stack.length;
20
+ }
21
+
22
+ get top(): StackEntry | undefined {
23
+ return this.stack[this.stack.length - 1];
24
+ }
25
+
26
+ isTop(id: string): boolean {
27
+ return this.top?.id === id;
28
+ }
29
+
30
+ push(entry: StackEntry): number {
31
+ const previous = this.top;
32
+ previous?.deactivate();
33
+
34
+ this.stack.push(entry);
35
+ const zIndex = this.computeZIndex(entry);
36
+ entry.activate();
37
+ return zIndex;
38
+ }
39
+
40
+ remove(id: string): void {
41
+ const index = this.stack.findIndex((entry) => entry.id === id);
42
+ if (index === -1) return;
43
+
44
+ const [removed] = this.stack.splice(index, 1);
45
+ removed?.deactivate();
46
+
47
+ const top = this.top;
48
+ if (top) {
49
+ top.activate();
50
+ }
51
+ }
52
+
53
+ computeZIndex(entry: StackEntry): number {
54
+ const index = this.stack.findIndex((item) => item.id === entry.id);
55
+ const layer = index === -1 ? this.stack.length : index;
56
+ const base = entry.zIndexBase || DEFAULT_Z_INDEX_BASE;
57
+ return base + layer * Z_INDEX_LAYER_STEP;
58
+ }
59
+
60
+ clear(): void {
61
+ while (this.stack.length) {
62
+ const entry = this.stack.pop();
63
+ entry?.deactivate();
64
+ }
65
+ }
66
+ }
67
+
68
+ export const modalStack = new ModalStackManager();
@@ -0,0 +1,78 @@
1
+ interface ScrollLockState {
2
+ count: number;
3
+ previousOverflow: string;
4
+ previousPaddingRight: string;
5
+ previousPosition: string;
6
+ previousTop: string;
7
+ previousWidth: string;
8
+ scrollY: number;
9
+ }
10
+
11
+ let state: ScrollLockState | null = null;
12
+
13
+ function getScrollbarWidth(): number {
14
+ if (typeof window === 'undefined') return 0;
15
+ return window.innerWidth - document.documentElement.clientWidth;
16
+ }
17
+
18
+ /**
19
+ * Reference-counted body scroll lock so nested modals unlock only when
20
+ * the last modal closes.
21
+ */
22
+ export function lockBodyScroll(): void {
23
+ if (typeof document === 'undefined') return;
24
+
25
+ const body = document.body;
26
+ if (!state) {
27
+ const scrollbarWidth = getScrollbarWidth();
28
+ state = {
29
+ count: 0,
30
+ previousOverflow: body.style.overflow,
31
+ previousPaddingRight: body.style.paddingRight,
32
+ previousPosition: body.style.position,
33
+ previousTop: body.style.top,
34
+ previousWidth: body.style.width,
35
+ scrollY: window.scrollY,
36
+ };
37
+
38
+ body.style.overflow = 'hidden';
39
+ if (scrollbarWidth > 0) {
40
+ const currentPadding = Number.parseFloat(getComputedStyle(body).paddingRight) || 0;
41
+ body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
42
+ }
43
+
44
+ // iOS Safari fallback
45
+ body.style.position = 'fixed';
46
+ body.style.top = `-${state.scrollY}px`;
47
+ body.style.width = '100%';
48
+ }
49
+
50
+ state.count += 1;
51
+ }
52
+
53
+ export function unlockBodyScroll(): void {
54
+ if (typeof document === 'undefined' || !state) return;
55
+
56
+ state.count -= 1;
57
+ if (state.count > 0) return;
58
+
59
+ const body = document.body;
60
+ const { previousOverflow, previousPaddingRight, previousPosition, previousTop, previousWidth, scrollY } =
61
+ state;
62
+
63
+ body.style.overflow = previousOverflow;
64
+ body.style.paddingRight = previousPaddingRight;
65
+ body.style.position = previousPosition;
66
+ body.style.top = previousTop;
67
+ body.style.width = previousWidth;
68
+ window.scrollTo(0, scrollY);
69
+ state = null;
70
+ }
71
+
72
+ /** Test helper — reset lock state. */
73
+ export function resetScrollLock(): void {
74
+ if (state) {
75
+ state.count = 1;
76
+ unlockBodyScroll();
77
+ }
78
+ }
@@ -0,0 +1,50 @@
1
+ import type { ModalTheme } from './types';
2
+
3
+ const THEME_VAR_MAP: Record<keyof Omit<ModalTheme, 'cssVars'>, string> = {
4
+ brand: '--um-color-brand',
5
+ brandForeground: '--um-color-brand-fg',
6
+ confirmBg: '--um-btn-confirm-bg',
7
+ confirmFg: '--um-btn-confirm-fg',
8
+ confirmBorder: '--um-btn-confirm-border',
9
+ cancelBg: '--um-btn-cancel-bg',
10
+ cancelFg: '--um-btn-cancel-fg',
11
+ cancelBorder: '--um-btn-cancel-border',
12
+ focusRing: '--um-focus-ring',
13
+ };
14
+
15
+ function toCssVarName(name: string): string {
16
+ if (name.startsWith('--')) return name;
17
+ if (name.startsWith('um-')) return `--${name}`;
18
+ return `--um-${name}`;
19
+ }
20
+
21
+ /**
22
+ * Apply brand / button theme tokens onto a modal root element.
23
+ */
24
+ export function applyModalTheme(target: HTMLElement, theme?: ModalTheme | null): void {
25
+ if (!theme) return;
26
+
27
+ for (const [key, cssVar] of Object.entries(THEME_VAR_MAP) as Array<
28
+ [keyof typeof THEME_VAR_MAP, string]
29
+ >) {
30
+ const value = theme[key];
31
+ if (typeof value === 'string' && value.length > 0) {
32
+ target.style.setProperty(cssVar, value);
33
+ }
34
+ }
35
+
36
+ // Keep accent aliases in sync when brand is customized.
37
+ if (theme.brand) {
38
+ target.style.setProperty('--um-color-accent', 'var(--um-color-brand)');
39
+ }
40
+ if (theme.brandForeground) {
41
+ target.style.setProperty('--um-color-accent-fg', 'var(--um-color-brand-fg)');
42
+ }
43
+
44
+ if (theme.cssVars) {
45
+ for (const [name, value] of Object.entries(theme.cssVars)) {
46
+ if (typeof value !== 'string') continue;
47
+ target.style.setProperty(toCssVarName(name), value);
48
+ }
49
+ }
50
+ }