@fivem-nui/svelte-components 0.0.0-stage → 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.
- package/README.md +101 -2
- package/dist/components/actions/NuiButton.svelte +292 -0
- package/dist/components/actions/NuiButton.svelte.d.ts +60 -0
- package/dist/components/data/NuiBadge.svelte +277 -0
- package/dist/components/data/NuiBadge.svelte.d.ts +74 -0
- package/dist/components/data/NuiCodeBlock.svelte +256 -0
- package/dist/components/data/NuiCodeBlock.svelte.d.ts +49 -0
- package/dist/components/data/NuiList.svelte +341 -0
- package/dist/components/data/NuiList.svelte.d.ts +52 -0
- package/dist/components/data/NuiProgressBar.svelte +264 -0
- package/dist/components/data/NuiProgressBar.svelte.d.ts +51 -0
- package/dist/components/data/NuiRichText.svelte +260 -0
- package/dist/components/data/NuiRichText.svelte.d.ts +33 -0
- package/dist/components/data/NuiTable.svelte +470 -0
- package/dist/components/data/NuiTable.svelte.d.ts +53 -0
- package/dist/components/data/chart/NuiChart.svelte +384 -0
- package/dist/components/data/chart/NuiChart.svelte.d.ts +49 -0
- package/dist/components/data/chart/color.d.ts +9 -0
- package/dist/components/data/chart/color.js +78 -0
- package/dist/components/data/chart/index.d.ts +10 -0
- package/dist/components/data/chart/index.js +10 -0
- package/dist/components/data/chart/registry.d.ts +14 -0
- package/dist/components/data/chart/registry.js +53 -0
- package/dist/components/data/chart/renderers/bar.d.ts +8 -0
- package/dist/components/data/chart/renderers/bar.js +79 -0
- package/dist/components/data/chart/renderers/base.d.ts +18 -0
- package/dist/components/data/chart/renderers/base.js +10 -0
- package/dist/components/data/chart/renderers/line.d.ts +8 -0
- package/dist/components/data/chart/renderers/line.js +89 -0
- package/dist/components/data/chart/renderers/pie.d.ts +10 -0
- package/dist/components/data/chart/renderers/pie.js +55 -0
- package/dist/components/data/chart/scale.d.ts +22 -0
- package/dist/components/data/chart/scale.js +101 -0
- package/dist/components/data/chart/types.d.ts +84 -0
- package/dist/components/data/chart/types.js +1 -0
- package/dist/components/data/chart/validator.d.ts +10 -0
- package/dist/components/data/chart/validator.js +82 -0
- package/dist/components/forms/NuiCheckbox.svelte +218 -0
- package/dist/components/forms/NuiCheckbox.svelte.d.ts +37 -0
- package/dist/components/forms/NuiDatepicker.svelte +755 -0
- package/dist/components/forms/NuiDatepicker.svelte.d.ts +34 -0
- package/dist/components/forms/NuiFileInput.svelte +1047 -0
- package/dist/components/forms/NuiFileInput.svelte.d.ts +93 -0
- package/dist/components/forms/NuiFileInput.types.d.ts +28 -0
- package/dist/components/forms/NuiFileInput.types.js +1 -0
- package/dist/components/forms/NuiForm.svelte +391 -0
- package/dist/components/forms/NuiForm.svelte.d.ts +69 -0
- package/dist/components/forms/NuiInput.svelte +296 -0
- package/dist/components/forms/NuiInput.svelte.d.ts +62 -0
- package/dist/components/forms/NuiRadioGroup.svelte +238 -0
- package/dist/components/forms/NuiRadioGroup.svelte.d.ts +47 -0
- package/dist/components/forms/NuiSelect.svelte +819 -0
- package/dist/components/forms/NuiSelect.svelte.d.ts +49 -0
- package/dist/components/layout/NuiCard.svelte +314 -0
- package/dist/components/layout/NuiCard.svelte.d.ts +48 -0
- package/dist/components/layout/NuiCardGrid.svelte +98 -0
- package/dist/components/layout/NuiCardGrid.svelte.d.ts +22 -0
- package/dist/components/layout/NuiFlex.svelte +121 -0
- package/dist/components/layout/NuiFlex.svelte.d.ts +23 -0
- package/dist/components/layout/NuiGrid.svelte +66 -0
- package/dist/components/layout/NuiGrid.svelte.d.ts +14 -0
- package/dist/components/layout/NuiStack.svelte +138 -0
- package/dist/components/layout/NuiStack.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiAccordion.svelte +323 -0
- package/dist/components/navigation/NuiAccordion.svelte.d.ts +38 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte.d.ts +19 -0
- package/dist/components/navigation/NuiMenu.svelte +596 -0
- package/dist/components/navigation/NuiMenu.svelte.d.ts +28 -0
- package/dist/components/navigation/NuiPagination.svelte +306 -0
- package/dist/components/navigation/NuiPagination.svelte.d.ts +40 -0
- package/dist/components/navigation/NuiSidebar.svelte +186 -0
- package/dist/components/navigation/NuiSidebar.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiTabs.svelte +249 -0
- package/dist/components/navigation/NuiTabs.svelte.d.ts +31 -0
- package/dist/components/overlay/NuiDialog.svelte +118 -0
- package/dist/components/overlay/NuiDialog.svelte.d.ts +34 -0
- package/dist/components/overlay/NuiModal.svelte +992 -0
- package/dist/components/overlay/NuiModal.svelte.d.ts +72 -0
- package/dist/components/overlay/NuiTooltip.svelte +159 -0
- package/dist/components/overlay/NuiTooltip.svelte.d.ts +13 -0
- package/dist/composables/useClickOutside.d.ts +43 -0
- package/dist/composables/useClickOutside.js +73 -0
- package/dist/composables/useFormValidation.d.ts +23 -0
- package/dist/composables/useFormValidation.js +72 -0
- package/dist/composables/useIdle.svelte.d.ts +18 -0
- package/dist/composables/useIdle.svelte.js +144 -0
- package/dist/composables/useKeyboardShortcut.svelte.d.ts +114 -0
- package/dist/composables/useKeyboardShortcut.svelte.js +153 -0
- package/dist/composables/useLocalStorage.svelte.d.ts +22 -0
- package/dist/composables/useLocalStorage.svelte.js +105 -0
- package/dist/composables/useMediaQuery.svelte.d.ts +51 -0
- package/dist/composables/useMediaQuery.svelte.js +80 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +42 -0
- package/package.json +58 -3
- package/src/components/actions/NuiButton.svelte +292 -0
- package/src/components/data/NuiBadge.svelte +277 -0
- package/src/components/data/NuiCodeBlock.svelte +256 -0
- package/src/components/data/NuiList.svelte +341 -0
- package/src/components/data/NuiProgressBar.svelte +264 -0
- package/src/components/data/NuiRichText.svelte +260 -0
- package/src/components/data/NuiTable.svelte +470 -0
- package/src/components/data/chart/NuiChart.svelte +384 -0
- package/src/components/data/chart/color.ts +83 -0
- package/src/components/data/chart/index.ts +10 -0
- package/src/components/data/chart/registry.ts +71 -0
- package/src/components/data/chart/renderers/bar.ts +90 -0
- package/src/components/data/chart/renderers/base.ts +32 -0
- package/src/components/data/chart/renderers/line.ts +99 -0
- package/src/components/data/chart/renderers/pie.ts +66 -0
- package/src/components/data/chart/scale.ts +139 -0
- package/src/components/data/chart/types.ts +98 -0
- package/src/components/data/chart/validator.ts +98 -0
- package/src/components/forms/NuiCheckbox.svelte +218 -0
- package/src/components/forms/NuiDatepicker.svelte +755 -0
- package/src/components/forms/NuiFileInput.svelte +1047 -0
- package/src/components/forms/NuiFileInput.types.ts +29 -0
- package/src/components/forms/NuiForm.svelte +391 -0
- package/src/components/forms/NuiInput.svelte +296 -0
- package/src/components/forms/NuiRadioGroup.svelte +238 -0
- package/src/components/forms/NuiSelect.svelte +819 -0
- package/src/components/layout/NuiCard.svelte +314 -0
- package/src/components/layout/NuiCardGrid.svelte +98 -0
- package/src/components/layout/NuiFlex.svelte +121 -0
- package/src/components/layout/NuiGrid.svelte +66 -0
- package/src/components/layout/NuiStack.svelte +138 -0
- package/src/components/navigation/NuiAccordion.svelte +323 -0
- package/src/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/src/components/navigation/NuiMenu.svelte +596 -0
- package/src/components/navigation/NuiPagination.svelte +306 -0
- package/src/components/navigation/NuiSidebar.svelte +186 -0
- package/src/components/navigation/NuiTabs.svelte +249 -0
- package/src/components/overlay/NuiDialog.svelte +118 -0
- package/src/components/overlay/NuiModal.svelte +992 -0
- package/src/components/overlay/NuiTooltip.svelte +159 -0
- package/src/composables/useClickOutside.ts +132 -0
- package/src/composables/useFormValidation.ts +105 -0
- package/src/composables/useIdle.svelte.ts +191 -0
- package/src/composables/useKeyboardShortcut.svelte.ts +278 -0
- package/src/composables/useLocalStorage.svelte.ts +137 -0
- package/src/composables/useMediaQuery.svelte.ts +102 -0
- package/src/index.ts +66 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export interface ModalAction {
|
|
3
|
+
/**
|
|
4
|
+
* Unique action identifier within the modal.
|
|
5
|
+
* Used as a keyed `#each` identifier and for `initialFocus` targeting.
|
|
6
|
+
*/
|
|
7
|
+
id?: string;
|
|
8
|
+
label: string;
|
|
9
|
+
variant?: "primary" | "secondary" | "danger";
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Automatically closes the modal after `onclick` succeeds (does not throw or return `false`). */
|
|
12
|
+
closeOnClick?: boolean;
|
|
13
|
+
/** Action callback. Returning `false` or throwing an error prevents `closeOnClick` from closing. */
|
|
14
|
+
onclick?: (event: MouseEvent) => unknown | Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModalLabels {
|
|
18
|
+
dialog?: string;
|
|
19
|
+
close?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ModalStackEntry {
|
|
23
|
+
token: symbol;
|
|
24
|
+
getBackdrop: () => HTMLElement | null;
|
|
25
|
+
getDialog: () => HTMLElement | null;
|
|
26
|
+
previousFocus: HTMLElement | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface UnregisterResult {
|
|
30
|
+
wasTop: boolean;
|
|
31
|
+
topDialog: HTMLElement | null;
|
|
32
|
+
candidateFocus: HTMLElement | null;
|
|
33
|
+
fallbackFocus: HTMLElement | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SCROLL_LOCK_CLASS = "nui-scroll-locked";
|
|
37
|
+
const modalStack: ModalStackEntry[] = [];
|
|
38
|
+
const inertedElements = new Map<HTMLElement, { ariaHidden: string | null }>();
|
|
39
|
+
let originalFocus: HTMLElement | null = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Portal action: moves the backdrop directly into `document.body` so that
|
|
43
|
+
* sibling isolation (`inert`) works reliably even when the app mounts inside `<div id="app">`.
|
|
44
|
+
*/
|
|
45
|
+
function portal(node: HTMLElement, target: HTMLElement = document.body) {
|
|
46
|
+
target.appendChild(node);
|
|
47
|
+
return {
|
|
48
|
+
destroy() {
|
|
49
|
+
node.remove();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Walks up from `activeBackdrop` to `document.body` and marks all sibling branches
|
|
56
|
+
* at every ancestor level as `inert` and `aria-hidden="true"`.
|
|
57
|
+
* Also isolates lower modals in the stack.
|
|
58
|
+
*/
|
|
59
|
+
function syncModalIsolation() {
|
|
60
|
+
if (typeof document === "undefined") return;
|
|
61
|
+
|
|
62
|
+
const topEntry = modalStack.at(-1);
|
|
63
|
+
|
|
64
|
+
if (!topEntry) {
|
|
65
|
+
for (const [el, prev] of inertedElements.entries()) {
|
|
66
|
+
el.inert = false;
|
|
67
|
+
if (prev.ariaHidden === null) {
|
|
68
|
+
el.removeAttribute("aria-hidden");
|
|
69
|
+
} else {
|
|
70
|
+
el.setAttribute("aria-hidden", prev.ariaHidden);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
inertedElements.clear();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const activeBackdrop = topEntry.getBackdrop();
|
|
78
|
+
if (!activeBackdrop || !activeBackdrop.isConnected) return;
|
|
79
|
+
|
|
80
|
+
const shouldBeInert = new Set<HTMLElement>();
|
|
81
|
+
|
|
82
|
+
let current: HTMLElement | null = activeBackdrop;
|
|
83
|
+
while (current && current !== document.body && current.parentElement) {
|
|
84
|
+
const parentEl: HTMLElement = current.parentElement;
|
|
85
|
+
for (const sibling of Array.from(parentEl.children)) {
|
|
86
|
+
if (
|
|
87
|
+
sibling instanceof HTMLElement &&
|
|
88
|
+
sibling !== current &&
|
|
89
|
+
sibling.tagName !== "SCRIPT" &&
|
|
90
|
+
sibling.tagName !== "STYLE"
|
|
91
|
+
) {
|
|
92
|
+
shouldBeInert.add(sibling);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
current = parentEl;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Isolate direct children of document.body except the branch containing activeBackdrop
|
|
99
|
+
for (const child of Array.from(document.body.children)) {
|
|
100
|
+
if (
|
|
101
|
+
child instanceof HTMLElement &&
|
|
102
|
+
child !== current &&
|
|
103
|
+
child.tagName !== "SCRIPT" &&
|
|
104
|
+
child.tagName !== "STYLE"
|
|
105
|
+
) {
|
|
106
|
+
shouldBeInert.add(child);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Explicitly ensure lower modals in the stack are inert and top modal is active
|
|
111
|
+
for (const entry of modalStack) {
|
|
112
|
+
const backdrop = entry.getBackdrop();
|
|
113
|
+
if (!backdrop) continue;
|
|
114
|
+
if (entry.token === topEntry.token) {
|
|
115
|
+
shouldBeInert.delete(backdrop);
|
|
116
|
+
} else {
|
|
117
|
+
shouldBeInert.add(backdrop);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Un-inert elements that no longer need isolation
|
|
122
|
+
for (const [el, prev] of inertedElements.entries()) {
|
|
123
|
+
if (!shouldBeInert.has(el)) {
|
|
124
|
+
el.inert = false;
|
|
125
|
+
if (prev.ariaHidden === null) {
|
|
126
|
+
el.removeAttribute("aria-hidden");
|
|
127
|
+
} else {
|
|
128
|
+
el.setAttribute("aria-hidden", prev.ariaHidden);
|
|
129
|
+
}
|
|
130
|
+
inertedElements.delete(el);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Apply inert to newly isolated elements (preserving pre-existing inert elements)
|
|
135
|
+
for (const el of shouldBeInert) {
|
|
136
|
+
if (!inertedElements.has(el)) {
|
|
137
|
+
if (el.inert) continue; // Already inerted by consumer code
|
|
138
|
+
inertedElements.set(el, { ariaHidden: el.getAttribute("aria-hidden") });
|
|
139
|
+
el.inert = true;
|
|
140
|
+
el.setAttribute("aria-hidden", "true");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function registerModal(entry: ModalStackEntry) {
|
|
146
|
+
if (modalStack.length === 0) {
|
|
147
|
+
originalFocus =
|
|
148
|
+
document.activeElement instanceof HTMLElement
|
|
149
|
+
? document.activeElement
|
|
150
|
+
: null;
|
|
151
|
+
document.body.classList.add(SCROLL_LOCK_CLASS);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Prevent duplicate registration of the same token
|
|
155
|
+
const existingIdx = modalStack.findIndex((e) => e.token === entry.token);
|
|
156
|
+
if (existingIdx !== -1) {
|
|
157
|
+
modalStack[existingIdx] = entry;
|
|
158
|
+
} else {
|
|
159
|
+
modalStack.push(entry);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
syncModalIsolation();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unregisterModal(
|
|
166
|
+
token: symbol,
|
|
167
|
+
closingDialogEl: HTMLElement | null,
|
|
168
|
+
): UnregisterResult {
|
|
169
|
+
const index = modalStack.findIndex((entry) => entry.token === token);
|
|
170
|
+
const wasTop = index !== -1 && index === modalStack.length - 1;
|
|
171
|
+
const removedEntry = index !== -1 ? modalStack[index] : undefined;
|
|
172
|
+
|
|
173
|
+
if (index !== -1) modalStack.splice(index, 1);
|
|
174
|
+
|
|
175
|
+
if (closingDialogEl) {
|
|
176
|
+
if (originalFocus && closingDialogEl.contains(originalFocus)) {
|
|
177
|
+
originalFocus = removedEntry?.previousFocus ?? null;
|
|
178
|
+
}
|
|
179
|
+
for (const entry of modalStack) {
|
|
180
|
+
if (
|
|
181
|
+
entry.previousFocus &&
|
|
182
|
+
closingDialogEl.contains(entry.previousFocus)
|
|
183
|
+
) {
|
|
184
|
+
entry.previousFocus = removedEntry?.previousFocus ?? null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
syncModalIsolation();
|
|
190
|
+
|
|
191
|
+
if (modalStack.length === 0) {
|
|
192
|
+
document.body.classList.remove(SCROLL_LOCK_CLASS);
|
|
193
|
+
const fallbackFocus = originalFocus;
|
|
194
|
+
originalFocus = null;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
wasTop,
|
|
198
|
+
topDialog: null,
|
|
199
|
+
candidateFocus: removedEntry?.previousFocus ?? null,
|
|
200
|
+
fallbackFocus,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
wasTop,
|
|
206
|
+
topDialog: modalStack.at(-1)?.getDialog() ?? null,
|
|
207
|
+
candidateFocus: removedEntry?.previousFocus ?? null,
|
|
208
|
+
fallbackFocus: null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isTopModal(token: symbol) {
|
|
213
|
+
return modalStack.at(-1)?.token === token;
|
|
214
|
+
}
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<script lang="ts">
|
|
218
|
+
import { tick } from "svelte";
|
|
219
|
+
import { fade, scale } from "svelte/transition";
|
|
220
|
+
import { cubicOut } from "svelte/easing";
|
|
221
|
+
import { DEV } from "esm-env";
|
|
222
|
+
import type { Snippet } from "svelte";
|
|
223
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
224
|
+
|
|
225
|
+
const FOCUSABLE_SELECTOR =
|
|
226
|
+
'a[href], area[href], button, input:not([type="hidden"]), select, textarea, iframe, [tabindex], [contenteditable="true"]';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Note on transitions: animations are tied to the internal `{#if open}` block.
|
|
230
|
+
* Control visibility via the `open` prop rather than wrapping `<NuiModal>` in an
|
|
231
|
+
* external `{#if show}` block so that exit transitions and focus cleanup run properly.
|
|
232
|
+
*/
|
|
233
|
+
interface Props
|
|
234
|
+
extends Omit<
|
|
235
|
+
HTMLAttributes<HTMLDivElement>,
|
|
236
|
+
"children" | "title" | "role" | "tabindex"
|
|
237
|
+
> {
|
|
238
|
+
/** Controls whether the modal is open. */
|
|
239
|
+
open?: boolean;
|
|
240
|
+
/** Modal header title. */
|
|
241
|
+
title?: string;
|
|
242
|
+
/** Optional description text rendered inside the body and linked via `aria-describedby`. */
|
|
243
|
+
description?: string;
|
|
244
|
+
/** Modal body content. */
|
|
245
|
+
children?: Snippet;
|
|
246
|
+
/**
|
|
247
|
+
* Custom header snippet. Receives `titleId` so you can bind `id={titleId}`
|
|
248
|
+
* to your custom heading element for `aria-labelledby`.
|
|
249
|
+
*/
|
|
250
|
+
header?: Snippet<[titleId: string]>;
|
|
251
|
+
/** Custom footer snippet (replaces the default `actions` bar). */
|
|
252
|
+
footer?: Snippet;
|
|
253
|
+
/** Footer action buttons. Each `action.id` must be unique if provided. */
|
|
254
|
+
actions?: ModalAction[];
|
|
255
|
+
/** Master switch for user dismissal. Individual flags (`closeOnEscape`, `closeOnBackdrop`, `showClose`) override this when defined. */
|
|
256
|
+
closable?: boolean;
|
|
257
|
+
/** Allow dismissing via the Escape key (defaults to `closable`). */
|
|
258
|
+
closeOnEscape?: boolean;
|
|
259
|
+
/** Allow dismissing by clicking the backdrop (defaults to `closable`). */
|
|
260
|
+
closeOnBackdrop?: boolean;
|
|
261
|
+
/** Render the header close ("X") button (defaults to `closable`). */
|
|
262
|
+
showClose?: boolean;
|
|
263
|
+
/** Modal width preset. */
|
|
264
|
+
size?: "sm" | "md" | "lg" | "xl" | "full";
|
|
265
|
+
/** Enable entry and exit transitions (respects `prefers-reduced-motion`). */
|
|
266
|
+
animated?: boolean;
|
|
267
|
+
/** Semantic ARIA role. */
|
|
268
|
+
role?: "dialog" | "alertdialog";
|
|
269
|
+
/** Action `id` or valid CSS selector to focus when the modal opens. */
|
|
270
|
+
initialFocus?: string;
|
|
271
|
+
/** Accessible labels for the dialog and close button. */
|
|
272
|
+
labels?: ModalLabels;
|
|
273
|
+
/**
|
|
274
|
+
* Called when the user dismisses the modal (via Escape, backdrop, close button, or an action with `closeOnClick`).
|
|
275
|
+
* Not called when `open` is set to `false` programmatically by the parent.
|
|
276
|
+
*/
|
|
277
|
+
onClose?: () => void;
|
|
278
|
+
/** Called when an action callback throws or rejects. */
|
|
279
|
+
onActionError?: (error: unknown, action: ModalAction) => void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const uid = $props.id();
|
|
283
|
+
const titleId = `nui-modal-${uid}-title`;
|
|
284
|
+
const descId = `nui-modal-${uid}-desc`;
|
|
285
|
+
const token = Symbol("nui-modal");
|
|
286
|
+
|
|
287
|
+
let {
|
|
288
|
+
open = $bindable(false),
|
|
289
|
+
title = "",
|
|
290
|
+
description = "",
|
|
291
|
+
children,
|
|
292
|
+
header,
|
|
293
|
+
footer,
|
|
294
|
+
actions = [],
|
|
295
|
+
closable = true,
|
|
296
|
+
closeOnEscape,
|
|
297
|
+
closeOnBackdrop,
|
|
298
|
+
showClose,
|
|
299
|
+
size = "md",
|
|
300
|
+
animated = false,
|
|
301
|
+
role = "dialog",
|
|
302
|
+
initialFocus,
|
|
303
|
+
labels = {},
|
|
304
|
+
onClose,
|
|
305
|
+
onActionError,
|
|
306
|
+
class: className = "",
|
|
307
|
+
"aria-label": userAriaLabel,
|
|
308
|
+
"aria-labelledby": userAriaLabelledby,
|
|
309
|
+
"aria-describedby": userAriaDescribedby,
|
|
310
|
+
...rest
|
|
311
|
+
}: Props = $props();
|
|
312
|
+
|
|
313
|
+
const canCloseOnEscape = $derived(closeOnEscape ?? closable);
|
|
314
|
+
const canCloseOnBackdrop = $derived(closeOnBackdrop ?? closable);
|
|
315
|
+
const shouldShowClose = $derived(showClose ?? closable);
|
|
316
|
+
|
|
317
|
+
const dialogLabel = $derived(labels.dialog ?? "Dialog");
|
|
318
|
+
const closeButtonLabel = $derived(labels.close ?? "Close");
|
|
319
|
+
|
|
320
|
+
const resolvedAriaLabelledby = $derived(
|
|
321
|
+
userAriaLabelledby ?? (title ? titleId : undefined),
|
|
322
|
+
);
|
|
323
|
+
const resolvedAriaLabel = $derived(
|
|
324
|
+
resolvedAriaLabelledby ? undefined : (userAriaLabel ?? dialogLabel),
|
|
325
|
+
);
|
|
326
|
+
const resolvedAriaDescribedby = $derived(
|
|
327
|
+
userAriaDescribedby ?? (description ? descId : undefined),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
let modalEl: HTMLElement | null = $state(null);
|
|
331
|
+
let backdropEl: HTMLElement | null = $state(null);
|
|
332
|
+
let pendingActionKey: string | null = $state(null);
|
|
333
|
+
let prefersReducedMotion = $state(false);
|
|
334
|
+
let isRegistered = false;
|
|
335
|
+
let lastKnownModalEl: HTMLElement | null = null;
|
|
336
|
+
|
|
337
|
+
$effect(() => {
|
|
338
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
339
|
+
|
|
340
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
341
|
+
prefersReducedMotion = mediaQuery.matches;
|
|
342
|
+
|
|
343
|
+
const listener = (e: MediaQueryListEvent) => {
|
|
344
|
+
prefersReducedMotion = e.matches;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
mediaQuery.addEventListener("change", listener);
|
|
348
|
+
return () => mediaQuery.removeEventListener("change", listener);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const transitionDuration = $derived(
|
|
352
|
+
animated && !prefersReducedMotion ? 150 : 0,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Checks whether an element and its ancestor chain are visible, not inert/hidden,
|
|
357
|
+
* and capable of receiving focus across both modern Chromium and older CEF builds.
|
|
358
|
+
*/
|
|
359
|
+
function isElementVisibleAndInteractive(
|
|
360
|
+
el: HTMLElement,
|
|
361
|
+
boundary?: HTMLElement | null,
|
|
362
|
+
): boolean {
|
|
363
|
+
if (!el.isConnected) return false;
|
|
364
|
+
if (el.matches(":disabled")) return false;
|
|
365
|
+
if (el.getClientRects().length === 0) return false;
|
|
366
|
+
if (el.closest('[inert], [aria-hidden="true"]')) return false;
|
|
367
|
+
|
|
368
|
+
if (typeof el.checkVisibility === "function") {
|
|
369
|
+
return el.checkVisibility({
|
|
370
|
+
visibilityProperty: true,
|
|
371
|
+
contentVisibilityAuto: true,
|
|
372
|
+
checkOpacity: false,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let current: HTMLElement | null = el;
|
|
377
|
+
while (current) {
|
|
378
|
+
if (current.inert || current.getAttribute("aria-hidden") === "true") {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
const style = window.getComputedStyle(current);
|
|
382
|
+
if (
|
|
383
|
+
style.display === "none" ||
|
|
384
|
+
style.visibility === "hidden" ||
|
|
385
|
+
style.visibility === "collapse" ||
|
|
386
|
+
style.contentVisibility === "hidden"
|
|
387
|
+
) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
if (current === boundary) break;
|
|
391
|
+
current = current.parentElement;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isFocusableCandidate(
|
|
398
|
+
el: HTMLElement | null | undefined,
|
|
399
|
+
root?: HTMLElement | null,
|
|
400
|
+
): el is HTMLElement {
|
|
401
|
+
if (!el || el.tabIndex < 0) return false;
|
|
402
|
+
return isElementVisibleAndInteractive(el, root);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getFocusables(root: HTMLElement | null): HTMLElement[] {
|
|
406
|
+
if (!root) return [];
|
|
407
|
+
return [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
408
|
+
(el) => isFocusableCandidate(el, root),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function resolveInitialFocusTarget(
|
|
413
|
+
root: HTMLElement,
|
|
414
|
+
specifier: string | undefined,
|
|
415
|
+
): HTMLElement | undefined {
|
|
416
|
+
if (!specifier) return undefined;
|
|
417
|
+
|
|
418
|
+
const byActionId = [
|
|
419
|
+
...root.querySelectorAll<HTMLElement>("[data-action]"),
|
|
420
|
+
].find((el) => el.dataset.action === specifier);
|
|
421
|
+
|
|
422
|
+
if (isFocusableCandidate(byActionId, root)) {
|
|
423
|
+
return byActionId;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const bySelector = root.querySelector<HTMLElement>(specifier);
|
|
428
|
+
if (isFocusableCandidate(bySelector, root)) {
|
|
429
|
+
return bySelector;
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
// Ignore invalid CSS selector syntax
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function releaseModalFromStack() {
|
|
439
|
+
if (!isRegistered) return;
|
|
440
|
+
isRegistered = false;
|
|
441
|
+
|
|
442
|
+
const closingEl = modalEl ?? lastKnownModalEl;
|
|
443
|
+
lastKnownModalEl = null;
|
|
444
|
+
|
|
445
|
+
const result = unregisterModal(token, closingEl);
|
|
446
|
+
if (!result.wasTop) return;
|
|
447
|
+
|
|
448
|
+
if (result.topDialog && result.topDialog.isConnected) {
|
|
449
|
+
if (
|
|
450
|
+
result.candidateFocus &&
|
|
451
|
+
result.topDialog.contains(result.candidateFocus) &&
|
|
452
|
+
isFocusableCandidate(result.candidateFocus, result.topDialog)
|
|
453
|
+
) {
|
|
454
|
+
result.candidateFocus.focus();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
(getFocusables(result.topDialog)[0] ?? result.topDialog).focus();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (
|
|
462
|
+
result.candidateFocus &&
|
|
463
|
+
!closingEl?.contains(result.candidateFocus) &&
|
|
464
|
+
isElementVisibleAndInteractive(result.candidateFocus)
|
|
465
|
+
) {
|
|
466
|
+
result.candidateFocus.focus();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (
|
|
471
|
+
result.fallbackFocus &&
|
|
472
|
+
!closingEl?.contains(result.fallbackFocus) &&
|
|
473
|
+
isElementVisibleAndInteractive(result.fallbackFocus)
|
|
474
|
+
) {
|
|
475
|
+
result.fallbackFocus.focus();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function requestClose(allowed: boolean) {
|
|
480
|
+
if (!allowed || !open || pendingActionKey !== null) return;
|
|
481
|
+
open = false;
|
|
482
|
+
onClose?.();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function handleActionClick(
|
|
486
|
+
action: ModalAction,
|
|
487
|
+
actionKey: string,
|
|
488
|
+
event: MouseEvent,
|
|
489
|
+
) {
|
|
490
|
+
if (action.disabled || pendingActionKey !== null) return;
|
|
491
|
+
|
|
492
|
+
pendingActionKey = actionKey;
|
|
493
|
+
try {
|
|
494
|
+
const result = await action.onclick?.(event);
|
|
495
|
+
if (action.closeOnClick && result !== false && open) {
|
|
496
|
+
open = false;
|
|
497
|
+
onClose?.();
|
|
498
|
+
}
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (onActionError) {
|
|
501
|
+
onActionError(error, action);
|
|
502
|
+
} else {
|
|
503
|
+
console.error("[NuiModal] Action handler failed:", error);
|
|
504
|
+
}
|
|
505
|
+
} finally {
|
|
506
|
+
pendingActionKey = null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Dev-mode warning for duplicate action IDs
|
|
511
|
+
$effect(() => {
|
|
512
|
+
if (!DEV) return;
|
|
513
|
+
const seen = new Set<string>();
|
|
514
|
+
for (const action of actions) {
|
|
515
|
+
if (!action.id) continue;
|
|
516
|
+
if (seen.has(action.id)) {
|
|
517
|
+
console.warn(
|
|
518
|
+
`[NuiModal] Duplicate action.id="${action.id}" detected. Action IDs must be unique.`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
seen.add(action.id);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Open lifecycle: register immediately, defer stack release until exit transition completes
|
|
526
|
+
$effect(() => {
|
|
527
|
+
if (!open) {
|
|
528
|
+
if (transitionDuration === 0 && isRegistered) {
|
|
529
|
+
releaseModalFromStack();
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const currentActive =
|
|
535
|
+
document.activeElement instanceof HTMLElement
|
|
536
|
+
? document.activeElement
|
|
537
|
+
: null;
|
|
538
|
+
|
|
539
|
+
let cancelled = false;
|
|
540
|
+
isRegistered = true;
|
|
541
|
+
|
|
542
|
+
registerModal({
|
|
543
|
+
token,
|
|
544
|
+
getBackdrop: () => backdropEl,
|
|
545
|
+
getDialog: () => modalEl,
|
|
546
|
+
previousFocus: currentActive,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
tick().then(() => {
|
|
550
|
+
if (cancelled || !open || !isTopModal(token)) return;
|
|
551
|
+
|
|
552
|
+
lastKnownModalEl = modalEl;
|
|
553
|
+
syncModalIsolation();
|
|
554
|
+
|
|
555
|
+
const root = modalEl;
|
|
556
|
+
if (!root) return;
|
|
557
|
+
|
|
558
|
+
const requested = resolveInitialFocusTarget(root, initialFocus);
|
|
559
|
+
|
|
560
|
+
const primary = root.querySelector<HTMLElement>(
|
|
561
|
+
'.nui-modal__button--primary:not([aria-disabled="true"]):not(:disabled)',
|
|
562
|
+
);
|
|
563
|
+
const validPrimary = isFocusableCandidate(primary, root)
|
|
564
|
+
? primary
|
|
565
|
+
: undefined;
|
|
566
|
+
|
|
567
|
+
const focusables = getFocusables(root);
|
|
568
|
+
const firstNonClose = focusables.find(
|
|
569
|
+
(el) => !el.classList.contains("nui-modal__close"),
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
const target =
|
|
573
|
+
requested ?? validPrimary ?? firstNonClose ?? focusables[0] ?? root;
|
|
574
|
+
|
|
575
|
+
target.focus();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return () => {
|
|
579
|
+
cancelled = true;
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Safety cleanup if the entire component is unmounted while open or mid-transition
|
|
584
|
+
$effect(() => {
|
|
585
|
+
return () => {
|
|
586
|
+
if (isRegistered) {
|
|
587
|
+
releaseModalFromStack();
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
function onWindowKeydown(event: KeyboardEvent) {
|
|
593
|
+
// Keep focus trapped even during the 150ms outro transition, but ignore Escape once closing
|
|
594
|
+
if (!isRegistered || !isTopModal(token)) return;
|
|
595
|
+
|
|
596
|
+
if (event.key === "Escape") {
|
|
597
|
+
if (open && canCloseOnEscape) {
|
|
598
|
+
event.preventDefault();
|
|
599
|
+
event.stopPropagation();
|
|
600
|
+
requestClose(canCloseOnEscape);
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (event.key !== "Tab") return;
|
|
606
|
+
|
|
607
|
+
const root = modalEl;
|
|
608
|
+
if (!root) return;
|
|
609
|
+
|
|
610
|
+
const active = document.activeElement;
|
|
611
|
+
|
|
612
|
+
// Fast path for CEF performance: if focus is already inside the modal, check raw candidates
|
|
613
|
+
// first so we only compute full visibility when wrapping around the first/last boundaries.
|
|
614
|
+
if (
|
|
615
|
+
active instanceof HTMLElement &&
|
|
616
|
+
root.contains(active) &&
|
|
617
|
+
active !== root
|
|
618
|
+
) {
|
|
619
|
+
const rawCandidates =
|
|
620
|
+
root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
621
|
+
if (rawCandidates.length > 2) {
|
|
622
|
+
const firstRaw = rawCandidates[0];
|
|
623
|
+
const lastRaw = rawCandidates[rawCandidates.length - 1];
|
|
624
|
+
if (
|
|
625
|
+
active !== firstRaw &&
|
|
626
|
+
active !== lastRaw &&
|
|
627
|
+
isFocusableCandidate(firstRaw, root) &&
|
|
628
|
+
isFocusableCandidate(lastRaw, root)
|
|
629
|
+
) {
|
|
630
|
+
return; // Let the browser handle native internal Tab navigation
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const focusables = getFocusables(root);
|
|
636
|
+
|
|
637
|
+
if (focusables.length === 0) {
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
root.focus();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const first = focusables[0];
|
|
644
|
+
const last = focusables[focusables.length - 1];
|
|
645
|
+
|
|
646
|
+
if (event.shiftKey && (active === first || active === root)) {
|
|
647
|
+
event.preventDefault();
|
|
648
|
+
last.focus();
|
|
649
|
+
} else if (!event.shiftKey && active === last) {
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
first.focus();
|
|
652
|
+
} else if (!root.contains(active)) {
|
|
653
|
+
event.preventDefault();
|
|
654
|
+
(event.shiftKey ? last : first).focus();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function onWindowFocusIn(event: FocusEvent) {
|
|
659
|
+
if (!isRegistered || !isTopModal(token)) return;
|
|
660
|
+
|
|
661
|
+
const root = modalEl;
|
|
662
|
+
const target = event.target;
|
|
663
|
+
|
|
664
|
+
if (!root || !(target instanceof Node) || root.contains(target)) return;
|
|
665
|
+
|
|
666
|
+
(getFocusables(root)[0] ?? root).focus();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let pointerDownOnBackdrop = false;
|
|
670
|
+
|
|
671
|
+
function onBackdropPointerDown(event: PointerEvent) {
|
|
672
|
+
pointerDownOnBackdrop = event.target === event.currentTarget;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function onBackdropClick(event: MouseEvent) {
|
|
676
|
+
const shouldClose =
|
|
677
|
+
pointerDownOnBackdrop &&
|
|
678
|
+
event.target === event.currentTarget &&
|
|
679
|
+
canCloseOnBackdrop;
|
|
680
|
+
|
|
681
|
+
pointerDownOnBackdrop = false;
|
|
682
|
+
|
|
683
|
+
if (shouldClose) requestClose(canCloseOnBackdrop);
|
|
684
|
+
}
|
|
685
|
+
</script>
|
|
686
|
+
|
|
687
|
+
<svelte:window onkeydown={onWindowKeydown} onfocusin={onWindowFocusIn} />
|
|
688
|
+
|
|
689
|
+
{#if open}
|
|
690
|
+
<!--
|
|
691
|
+
svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions:
|
|
692
|
+
The backdrop is a purely visual scrim (`role="presentation"`). Keyboard dismissal is handled
|
|
693
|
+
via the Escape key on `<svelte:window>` and the explicit close button inside the dialog.
|
|
694
|
+
-->
|
|
695
|
+
<div
|
|
696
|
+
use:portal
|
|
697
|
+
bind:this={backdropEl}
|
|
698
|
+
class={["nui-modal-backdrop", !open && "nui-modal-backdrop--closing"]}
|
|
699
|
+
role="presentation"
|
|
700
|
+
transition:fade={{ duration: transitionDuration }}
|
|
701
|
+
onoutroend={releaseModalFromStack}
|
|
702
|
+
onpointerdown={onBackdropPointerDown}
|
|
703
|
+
onclick={onBackdropClick}
|
|
704
|
+
>
|
|
705
|
+
<!--
|
|
706
|
+
svelte-ignore a11y_no_noninteractive_tabindex:
|
|
707
|
+
The dialog container is focusable via script (tabindex="-1") for programmatic focus management.
|
|
708
|
+
-->
|
|
709
|
+
<div
|
|
710
|
+
{...rest}
|
|
711
|
+
bind:this={modalEl}
|
|
712
|
+
class={["nui-modal", `nui-modal--${size}`, className]}
|
|
713
|
+
{role}
|
|
714
|
+
aria-modal="true"
|
|
715
|
+
aria-labelledby={resolvedAriaLabelledby}
|
|
716
|
+
aria-label={resolvedAriaLabel}
|
|
717
|
+
aria-describedby={resolvedAriaDescribedby}
|
|
718
|
+
tabindex="-1"
|
|
719
|
+
transition:scale={{
|
|
720
|
+
duration: transitionDuration,
|
|
721
|
+
start: transitionDuration > 0 ? 0.96 : 1,
|
|
722
|
+
easing: cubicOut,
|
|
723
|
+
}}
|
|
724
|
+
>
|
|
725
|
+
{#if header}
|
|
726
|
+
<header class="nui-modal__header">
|
|
727
|
+
{#if title && !userAriaLabelledby}
|
|
728
|
+
<span id={titleId} class="nui-modal__sr-only">{title}</span>
|
|
729
|
+
{/if}
|
|
730
|
+
{@render header(titleId)}
|
|
731
|
+
</header>
|
|
732
|
+
{:else if title || shouldShowClose}
|
|
733
|
+
<header class="nui-modal__header">
|
|
734
|
+
{#if title}
|
|
735
|
+
<h2 id={titleId} class="nui-modal__title">{title}</h2>
|
|
736
|
+
{/if}
|
|
737
|
+
|
|
738
|
+
{#if shouldShowClose}
|
|
739
|
+
<button
|
|
740
|
+
type="button"
|
|
741
|
+
class="nui-modal__close"
|
|
742
|
+
aria-label={closeButtonLabel}
|
|
743
|
+
onclick={() => requestClose(shouldShowClose)}
|
|
744
|
+
>
|
|
745
|
+
<svg
|
|
746
|
+
aria-hidden="true"
|
|
747
|
+
viewBox="0 0 24 24"
|
|
748
|
+
width="18"
|
|
749
|
+
height="18"
|
|
750
|
+
fill="none"
|
|
751
|
+
stroke="currentColor"
|
|
752
|
+
stroke-width="2"
|
|
753
|
+
stroke-linecap="round"
|
|
754
|
+
stroke-linejoin="round"
|
|
755
|
+
>
|
|
756
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
757
|
+
</svg>
|
|
758
|
+
</button>
|
|
759
|
+
{/if}
|
|
760
|
+
</header>
|
|
761
|
+
{/if}
|
|
762
|
+
|
|
763
|
+
{#if description || children}
|
|
764
|
+
<div class="nui-modal__body">
|
|
765
|
+
{#if description}
|
|
766
|
+
<p id={descId} class="nui-modal__description">{description}</p>
|
|
767
|
+
{/if}
|
|
768
|
+
{@render children?.()}
|
|
769
|
+
</div>
|
|
770
|
+
{/if}
|
|
771
|
+
|
|
772
|
+
{#if footer}
|
|
773
|
+
<footer class="nui-modal__footer">
|
|
774
|
+
{@render footer()}
|
|
775
|
+
</footer>
|
|
776
|
+
{:else if actions.length > 0}
|
|
777
|
+
<footer class="nui-modal__footer">
|
|
778
|
+
{#each actions as action, index (action.id ?? index)}
|
|
779
|
+
{@const actionKey = action.id ?? String(index)}
|
|
780
|
+
{@const isPending = pendingActionKey !== null}
|
|
781
|
+
<button
|
|
782
|
+
type="button"
|
|
783
|
+
data-action={action.id}
|
|
784
|
+
class="nui-modal__button nui-modal__button--{action.variant ??
|
|
785
|
+
'secondary'}"
|
|
786
|
+
disabled={action.disabled}
|
|
787
|
+
aria-disabled={action.disabled || isPending ? "true" : undefined}
|
|
788
|
+
aria-busy={pendingActionKey === actionKey ? "true" : undefined}
|
|
789
|
+
onclick={(e) => handleActionClick(action, actionKey, e)}
|
|
790
|
+
>
|
|
791
|
+
{action.label}
|
|
792
|
+
</button>
|
|
793
|
+
{/each}
|
|
794
|
+
</footer>
|
|
795
|
+
{/if}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
{/if}
|
|
799
|
+
|
|
800
|
+
<style>
|
|
801
|
+
:global(body.nui-scroll-locked) {
|
|
802
|
+
overflow: hidden !important;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.nui-modal-backdrop {
|
|
806
|
+
position: fixed;
|
|
807
|
+
inset: 0;
|
|
808
|
+
z-index: var(--nui-z-modal, 1000);
|
|
809
|
+
display: flex;
|
|
810
|
+
align-items: center;
|
|
811
|
+
justify-content: center;
|
|
812
|
+
box-sizing: border-box;
|
|
813
|
+
padding: 1rem;
|
|
814
|
+
background: var(--nui-scrim, rgb(0 0 0 / 0.65));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
.nui-modal-backdrop--closing {
|
|
818
|
+
pointer-events: none;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.nui-modal {
|
|
822
|
+
display: flex;
|
|
823
|
+
flex-direction: column;
|
|
824
|
+
box-sizing: border-box;
|
|
825
|
+
max-width: calc(100vw - 2rem);
|
|
826
|
+
max-height: calc(100vh - 2rem);
|
|
827
|
+
overflow: hidden;
|
|
828
|
+
background: var(--nui-surface, #18181b);
|
|
829
|
+
color: var(--nui-fg, #fafafa);
|
|
830
|
+
border: 1px solid var(--nui-border, #27272a);
|
|
831
|
+
border-radius: var(--nui-radius, 0.5rem);
|
|
832
|
+
box-shadow: 0 25px 60px rgb(0 0 0 / 0.45);
|
|
833
|
+
font-family: inherit;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.nui-modal:focus-visible {
|
|
837
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
838
|
+
outline-offset: -3px;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.nui-modal--sm {
|
|
842
|
+
width: 20rem;
|
|
843
|
+
}
|
|
844
|
+
.nui-modal--md {
|
|
845
|
+
width: 30rem;
|
|
846
|
+
}
|
|
847
|
+
.nui-modal--lg {
|
|
848
|
+
width: 45rem;
|
|
849
|
+
}
|
|
850
|
+
.nui-modal--xl {
|
|
851
|
+
width: 60rem;
|
|
852
|
+
}
|
|
853
|
+
.nui-modal--full {
|
|
854
|
+
width: calc(100vw - 2rem);
|
|
855
|
+
height: calc(100vh - 2rem);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.nui-modal__sr-only {
|
|
859
|
+
position: absolute;
|
|
860
|
+
width: 1px;
|
|
861
|
+
height: 1px;
|
|
862
|
+
padding: 0;
|
|
863
|
+
margin: -1px;
|
|
864
|
+
overflow: hidden;
|
|
865
|
+
clip: rect(0, 0, 0, 0);
|
|
866
|
+
white-space: nowrap;
|
|
867
|
+
border: 0;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.nui-modal__header {
|
|
871
|
+
display: flex;
|
|
872
|
+
flex: none;
|
|
873
|
+
align-items: center;
|
|
874
|
+
justify-content: space-between;
|
|
875
|
+
gap: 0.75rem;
|
|
876
|
+
padding: 0.875rem 1.25rem;
|
|
877
|
+
border-bottom: 1px solid var(--nui-border, #27272a);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
.nui-modal__title {
|
|
881
|
+
margin: 0;
|
|
882
|
+
font: inherit;
|
|
883
|
+
font-size: 1.125rem;
|
|
884
|
+
font-weight: 600;
|
|
885
|
+
line-height: 1.35;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.nui-modal__close {
|
|
889
|
+
display: inline-flex;
|
|
890
|
+
align-items: center;
|
|
891
|
+
justify-content: center;
|
|
892
|
+
margin-left: auto;
|
|
893
|
+
border: 0;
|
|
894
|
+
border-radius: var(--nui-radius-sm, 0.25rem);
|
|
895
|
+
background: transparent;
|
|
896
|
+
color: inherit;
|
|
897
|
+
cursor: pointer;
|
|
898
|
+
opacity: 0.65;
|
|
899
|
+
padding: 0.25rem;
|
|
900
|
+
transition:
|
|
901
|
+
opacity 0.15s ease,
|
|
902
|
+
background-color 0.15s ease;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.nui-modal__close:hover {
|
|
906
|
+
opacity: 1;
|
|
907
|
+
background: var(--nui-hover, rgb(255 255 255 / 0.06));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.nui-modal__close:focus-visible {
|
|
911
|
+
opacity: 1;
|
|
912
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
913
|
+
outline-offset: 2px;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.nui-modal__body {
|
|
917
|
+
display: flex;
|
|
918
|
+
flex-direction: column;
|
|
919
|
+
gap: 0.75rem;
|
|
920
|
+
padding: 1.25rem;
|
|
921
|
+
flex: 1 1 auto;
|
|
922
|
+
overflow-y: auto;
|
|
923
|
+
min-height: 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
.nui-modal__description {
|
|
927
|
+
margin: 0;
|
|
928
|
+
opacity: 0.85;
|
|
929
|
+
line-height: 1.5;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
.nui-modal__footer {
|
|
933
|
+
display: flex;
|
|
934
|
+
flex: none;
|
|
935
|
+
flex-wrap: wrap;
|
|
936
|
+
gap: 0.5rem;
|
|
937
|
+
padding: 0.875rem 1.25rem;
|
|
938
|
+
border-top: 1px solid var(--nui-border, #27272a);
|
|
939
|
+
justify-content: flex-end;
|
|
940
|
+
background: var(--nui-surface-alt, #101012);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.nui-modal__button {
|
|
944
|
+
padding: 0.5rem 1rem;
|
|
945
|
+
border: 1px solid transparent;
|
|
946
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
947
|
+
font: inherit;
|
|
948
|
+
font-weight: 500;
|
|
949
|
+
cursor: pointer;
|
|
950
|
+
transition:
|
|
951
|
+
opacity 0.15s ease,
|
|
952
|
+
background-color 0.15s ease;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.nui-modal__button:hover:not(:disabled):not([aria-disabled="true"]) {
|
|
956
|
+
opacity: 0.85;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.nui-modal__button:focus-visible {
|
|
960
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
961
|
+
outline-offset: 2px;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.nui-modal__button:disabled,
|
|
965
|
+
.nui-modal__button[aria-disabled="true"] {
|
|
966
|
+
opacity: 0.4;
|
|
967
|
+
cursor: not-allowed;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.nui-modal__button--primary {
|
|
971
|
+
background: var(--nui-primary, #3b82f6);
|
|
972
|
+
color: var(--nui-primary-fg, #fff);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.nui-modal__button--secondary {
|
|
976
|
+
background: transparent;
|
|
977
|
+
border-color: var(--nui-border, #3f3f46);
|
|
978
|
+
color: var(--nui-fg, #fafafa);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.nui-modal__button--danger {
|
|
982
|
+
background: var(--nui-danger, #ef4444);
|
|
983
|
+
color: var(--nui-danger-fg, #fff);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
@media (prefers-reduced-motion: reduce) {
|
|
987
|
+
.nui-modal__close,
|
|
988
|
+
.nui-modal__button {
|
|
989
|
+
transition: none;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
</style>
|