vanilla-jui 1.4.7 → 1.5.1
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 +10 -4
- package/dist/index.d.ts +418 -970
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/components/accordion.ts +466 -346
- package/src/components/flow.ts +779 -1133
- package/src/components/form.ts +666 -509
- package/src/components/menu.ts +198 -328
- package/src/components/modal.ts +510 -747
- package/src/components/offcanvas.ts +273 -367
- package/src/components/pagination.ts +201 -375
- package/src/components/swiper.ts +689 -1107
- package/src/components/tabs.ts +467 -778
- package/src/components/toc.ts +191 -244
- package/src/core/component.ts +332 -0
- package/src/core/motion.ts +298 -0
- package/src/core/presence.ts +233 -0
- package/src/core/scheduler.ts +91 -0
- package/src/core/view.ts +31 -0
- package/src/css/accordion.css +94 -3
- package/src/css/content.css +18 -24
- package/src/css/drop.css +4 -1
- package/src/css/form.css +8 -0
- package/src/css/menu.css +10 -5
- package/src/css/modal.css +2 -1
- package/src/css/offcanvas.css +5 -17
- package/src/css/swiper.css +2 -1
- package/src/css/tabs.css +8 -17
- package/src/css/themes/default/accordion.css +0 -48
- package/src/css/themes/default/swiper.css +1 -1
- package/src/css/themes/default/tabs.css +4 -6
- package/src/css/toast.css +1 -17
- package/src/css/tooltip.css +2 -1
- package/src/index.ts +8 -2
- package/src/locales/index.ts +3 -0
- package/src/primitives/drop.ts +253 -262
- package/src/primitives/loading.ts +22 -4
- package/src/primitives/parabola.ts +123 -164
- package/src/primitives/sticky.ts +297 -0
- package/src/primitives/theme.ts +164 -222
- package/src/primitives/toast.ts +309 -284
- package/src/primitives/tooltip.ts +65 -66
- package/src/utilities/browser.ts +38 -0
- package/src/utilities/dom.ts +0 -54
- package/src/utilities/refs.ts +61 -0
- package/src/utilities/state.ts +73 -0
- package/src/utilities/timer.ts +11 -31
- package/src/validation/validator.ts +289 -389
- package/src/components/sticky.ts +0 -297
- package/src/core/Component.ts +0 -377
package/src/components/modal.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createDeepStore,
|
|
3
3
|
createEffect,
|
|
4
|
-
|
|
4
|
+
createSignal,
|
|
5
5
|
flushSync,
|
|
6
6
|
jsx,
|
|
7
|
-
onCleanup,
|
|
8
|
-
render,
|
|
9
7
|
untrack,
|
|
10
8
|
} from 'vanilla-signal';
|
|
11
9
|
import { t } from 'vanilla-signal-i18n';
|
|
12
10
|
|
|
13
|
-
import
|
|
14
|
-
type
|
|
15
|
-
|
|
16
|
-
} from '../core/
|
|
11
|
+
import {
|
|
12
|
+
type FunctionalComponent,
|
|
13
|
+
defineComponent,
|
|
14
|
+
} from '../core/component.ts';
|
|
17
15
|
import locales from '../locales/index.ts';
|
|
18
16
|
import { icon } from '../primitives/icons.ts';
|
|
19
17
|
import { createLoading } from '../primitives/loading.ts';
|
|
@@ -22,29 +20,26 @@ import { joinClasses } from '../utilities/class-name.ts';
|
|
|
22
20
|
import {
|
|
23
21
|
all,
|
|
24
22
|
q,
|
|
25
|
-
normalizeContentNodes,
|
|
26
23
|
type RenderableContent,
|
|
27
24
|
} from '../utilities/dom.ts';
|
|
28
25
|
import { randomId } from '../utilities/id.ts';
|
|
26
|
+
import { createMotionGroup, createTransition } from '../core/motion.ts';
|
|
29
27
|
import { isPlainObject } from '../utilities/object.ts';
|
|
28
|
+
import { createPresence } from '../core/presence.ts';
|
|
30
29
|
import {
|
|
31
30
|
type ResolveSchema,
|
|
32
31
|
resolveProps,
|
|
33
32
|
validateParam,
|
|
34
33
|
} from '../utilities/types.ts';
|
|
35
|
-
import {
|
|
36
|
-
createForm,
|
|
37
|
-
type FormDataRecord,
|
|
38
|
-
type FormField,
|
|
39
|
-
type FormProps,
|
|
40
|
-
} from './form.ts';
|
|
41
|
-
|
|
42
|
-
const HIDE_DURATION = 300;
|
|
43
34
|
|
|
44
35
|
type ModalTextInput = Partial<ModalText> & Record<string, unknown>;
|
|
45
|
-
type
|
|
46
|
-
type
|
|
47
|
-
|
|
36
|
+
type ModalContentResult = RenderableContent<Modal>;
|
|
37
|
+
type ModalContent =
|
|
38
|
+
| ModalContentResult
|
|
39
|
+
| ((modal: Modal) => ModalContentResult | Promise<ModalContentResult>);
|
|
40
|
+
interface ResolvedModalContent {
|
|
41
|
+
value: ModalContentResult;
|
|
42
|
+
}
|
|
48
43
|
|
|
49
44
|
export interface ModalClassNames {
|
|
50
45
|
layout: string;
|
|
@@ -68,8 +63,9 @@ export interface ModalText {
|
|
|
68
63
|
}
|
|
69
64
|
|
|
70
65
|
export interface ModalProps extends Record<string, unknown> {
|
|
71
|
-
mode?: ModalMode | null;
|
|
72
66
|
content?: ModalContent;
|
|
67
|
+
cache?: boolean;
|
|
68
|
+
ttl?: number;
|
|
73
69
|
position?: string;
|
|
74
70
|
showCancel?: boolean;
|
|
75
71
|
showClose?: boolean;
|
|
@@ -80,11 +76,7 @@ export interface ModalProps extends Record<string, unknown> {
|
|
|
80
76
|
onHide?: ((modal: Modal) => void | Promise<void>) | null;
|
|
81
77
|
onHidden?: ((modal: Modal) => void | Promise<void>) | null;
|
|
82
78
|
onConfirm?: ((modal: Modal) => void | Promise<void>) | null;
|
|
83
|
-
onSubmit?:
|
|
84
|
-
| ((data: FormDataRecord, modal: Modal) => void | Promise<void>)
|
|
85
|
-
| null;
|
|
86
79
|
onCancel?: ((modal: Modal) => void | Promise<void>) | null;
|
|
87
|
-
fields?: readonly FormField[] | null;
|
|
88
80
|
header?: boolean;
|
|
89
81
|
footer?: boolean;
|
|
90
82
|
id?: string | null;
|
|
@@ -94,8 +86,9 @@ export interface ModalProps extends Record<string, unknown> {
|
|
|
94
86
|
}
|
|
95
87
|
|
|
96
88
|
interface ResolvedModalProps extends Record<string, unknown> {
|
|
97
|
-
mode: ModalMode;
|
|
98
89
|
content: ModalContent;
|
|
90
|
+
cache: boolean;
|
|
91
|
+
ttl: number;
|
|
99
92
|
position: string;
|
|
100
93
|
showCancel: boolean;
|
|
101
94
|
showClose: boolean;
|
|
@@ -106,9 +99,7 @@ interface ResolvedModalProps extends Record<string, unknown> {
|
|
|
106
99
|
onHide: NonNullable<ModalProps['onHide']> | null;
|
|
107
100
|
onHidden: NonNullable<ModalProps['onHidden']> | null;
|
|
108
101
|
onConfirm: NonNullable<ModalProps['onConfirm']> | null;
|
|
109
|
-
onSubmit: NonNullable<ModalProps['onSubmit']> | null;
|
|
110
102
|
onCancel: NonNullable<ModalProps['onCancel']> | null;
|
|
111
|
-
fields: FormField[] | null;
|
|
112
103
|
header: boolean;
|
|
113
104
|
footer: boolean;
|
|
114
105
|
id: string;
|
|
@@ -118,42 +109,19 @@ interface ResolvedModalProps extends Record<string, unknown> {
|
|
|
118
109
|
}
|
|
119
110
|
|
|
120
111
|
interface ModalState extends Record<string, unknown> {
|
|
121
|
-
mode: ModalMode;
|
|
122
112
|
content: ModalContent;
|
|
123
|
-
fields: FormField[] | null;
|
|
124
113
|
loading: boolean;
|
|
125
114
|
processing: boolean;
|
|
126
115
|
visible: boolean;
|
|
127
|
-
data: FormDataRecord | null;
|
|
128
|
-
extraData: FormDataRecord | null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
interface ModalDOM extends ComponentDOM {
|
|
132
|
-
root: HTMLElement | null;
|
|
133
|
-
modal: HTMLElement | null;
|
|
134
|
-
header: HTMLElement | null;
|
|
135
|
-
body: HTMLElement | null;
|
|
136
|
-
footer: HTMLElement | null;
|
|
137
|
-
form: FormInstance | null;
|
|
138
|
-
formContainer: HTMLElement | null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface ModalRuntime extends ComponentRuntime {
|
|
142
|
-
scrollLocked: boolean;
|
|
143
|
-
visibleApplied: boolean;
|
|
144
116
|
}
|
|
145
117
|
|
|
146
118
|
interface ModalCache {
|
|
147
|
-
initial: ResolvedModalProps
|
|
148
|
-
fieldIds: Map<string, string> | null;
|
|
119
|
+
initial: ResolvedModalProps;
|
|
149
120
|
previousActiveElement: HTMLElement | null;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
visibility?: (() => void) | null;
|
|
155
|
-
view?: (() => void) | null;
|
|
156
|
-
hideTimer?: ReturnType<typeof setTimeout> | null;
|
|
121
|
+
content: ModalContentResult;
|
|
122
|
+
contentSource: ModalContent | null;
|
|
123
|
+
hasContent: boolean;
|
|
124
|
+
updatedAt: number;
|
|
157
125
|
}
|
|
158
126
|
|
|
159
127
|
type ModalStatePatch = Partial<ModalState>;
|
|
@@ -171,25 +139,9 @@ const DEFAULT_CLASS_NAMES: ModalClassNames = {
|
|
|
171
139
|
confirmBtn: 'is-primary',
|
|
172
140
|
};
|
|
173
141
|
|
|
174
|
-
function cloneFields(
|
|
175
|
-
fields: readonly FormField[] | null | undefined
|
|
176
|
-
): FormField[] {
|
|
177
|
-
if (!Array.isArray(fields)) return [];
|
|
178
|
-
return fields.map((field) => ({
|
|
179
|
-
...field,
|
|
180
|
-
options: Array.isArray(field.options)
|
|
181
|
-
? field.options.map(
|
|
182
|
-
(option: NonNullable<FormField['options']>[number]) =>
|
|
183
|
-
option && typeof option === 'object' ? { ...option } : option
|
|
184
|
-
)
|
|
185
|
-
: field.options,
|
|
186
|
-
}));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
142
|
function cloneProps(props: ResolvedModalProps): ResolvedModalProps {
|
|
190
143
|
return {
|
|
191
144
|
...props,
|
|
192
|
-
fields: Array.isArray(props.fields) ? cloneFields(props.fields) : null,
|
|
193
145
|
text: { ...props.text },
|
|
194
146
|
className: { ...props.className },
|
|
195
147
|
};
|
|
@@ -197,23 +149,19 @@ function cloneProps(props: ResolvedModalProps): ResolvedModalProps {
|
|
|
197
149
|
|
|
198
150
|
function createModalState(props: ResolvedModalProps): ModalState {
|
|
199
151
|
return {
|
|
200
|
-
mode: props.mode,
|
|
201
152
|
content: props.content,
|
|
202
|
-
fields: Array.isArray(props.fields) ? cloneFields(props.fields) : null,
|
|
203
153
|
loading: false,
|
|
204
154
|
processing: false,
|
|
205
155
|
visible: false,
|
|
206
|
-
data: null,
|
|
207
|
-
extraData: null,
|
|
208
156
|
};
|
|
209
157
|
}
|
|
210
158
|
|
|
211
|
-
function
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return
|
|
159
|
+
function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
|
|
160
|
+
return !!value && typeof (value as PromiseLike<T>).then === 'function';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeTtl(ttl: number): number {
|
|
164
|
+
return Number.isFinite(ttl) ? Math.max(0, ttl) : 0;
|
|
217
165
|
}
|
|
218
166
|
|
|
219
167
|
const MODAL_CONTENT_RULE = {
|
|
@@ -235,18 +183,10 @@ const MODAL_TEXT_RULE = {
|
|
|
235
183
|
},
|
|
236
184
|
};
|
|
237
185
|
|
|
238
|
-
const MODAL_FIELDS_RULE = { types: ['array', 'null'] };
|
|
239
186
|
const MODAL_PROPS_SCHEMA = {
|
|
240
|
-
mode: {
|
|
241
|
-
default: null,
|
|
242
|
-
types: ['string', 'null'],
|
|
243
|
-
enum: ['content', 'form', null],
|
|
244
|
-
normalize: (value: unknown, context) => {
|
|
245
|
-
if (value != null) return value;
|
|
246
|
-
return Array.isArray(context.options.fields) ? 'form' : 'content';
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
187
|
content: { default: '', ...MODAL_CONTENT_RULE },
|
|
188
|
+
cache: { default: false, type: 'boolean' },
|
|
189
|
+
ttl: { default: 0, type: 'number' },
|
|
250
190
|
position: { default: 'center', type: 'string' },
|
|
251
191
|
showCancel: { default: true, type: 'boolean' },
|
|
252
192
|
showClose: { default: true, type: 'boolean' },
|
|
@@ -257,9 +197,7 @@ const MODAL_PROPS_SCHEMA = {
|
|
|
257
197
|
onHide: { default: null, types: ['function', 'null'] },
|
|
258
198
|
onHidden: { default: null, types: ['function', 'null'] },
|
|
259
199
|
onConfirm: { default: null, types: ['function', 'null'] },
|
|
260
|
-
onSubmit: { default: null, types: ['function', 'null'] },
|
|
261
200
|
onCancel: { default: null, types: ['function', 'null'] },
|
|
262
|
-
fields: { default: null, ...MODAL_FIELDS_RULE },
|
|
263
201
|
header: { default: true, type: 'boolean' },
|
|
264
202
|
footer: { default: true, type: 'boolean' },
|
|
265
203
|
id: {
|
|
@@ -287,14 +225,10 @@ const MODAL_PROPS_SCHEMA = {
|
|
|
287
225
|
} satisfies ResolveSchema<ModalProps>;
|
|
288
226
|
|
|
289
227
|
const MODAL_STATE_SCHEMA = {
|
|
290
|
-
mode: { type: 'string', enum: ['content', 'form'] },
|
|
291
228
|
content: MODAL_PROPS_SCHEMA.content,
|
|
292
|
-
fields: MODAL_PROPS_SCHEMA.fields,
|
|
293
229
|
loading: { default: false, type: 'boolean' },
|
|
294
230
|
processing: { default: false, type: 'boolean' },
|
|
295
231
|
visible: { default: false, type: 'boolean' },
|
|
296
|
-
data: { default: null, types: ['object', 'null'] },
|
|
297
|
-
extraData: { default: null, types: ['object', 'null'] },
|
|
298
232
|
};
|
|
299
233
|
|
|
300
234
|
const MODAL_UPDATE_RULE = {
|
|
@@ -304,8 +238,9 @@ const MODAL_UPDATE_RULE = {
|
|
|
304
238
|
function normalizeProps(input: ModalProps): ResolvedModalProps {
|
|
305
239
|
const props = resolveProps(input, MODAL_PROPS_SCHEMA, 'Modal');
|
|
306
240
|
return {
|
|
307
|
-
mode: props.mode as ModalMode,
|
|
308
241
|
content: props.content as ModalContent,
|
|
242
|
+
cache: props.cache as boolean,
|
|
243
|
+
ttl: normalizeTtl(props.ttl as number),
|
|
309
244
|
position: props.position as string,
|
|
310
245
|
showCancel: props.showCancel as boolean,
|
|
311
246
|
showClose: props.showClose as boolean,
|
|
@@ -316,11 +251,7 @@ function normalizeProps(input: ModalProps): ResolvedModalProps {
|
|
|
316
251
|
onHide: props.onHide as ResolvedModalProps['onHide'],
|
|
317
252
|
onHidden: props.onHidden as ResolvedModalProps['onHidden'],
|
|
318
253
|
onConfirm: props.onConfirm as ResolvedModalProps['onConfirm'],
|
|
319
|
-
onSubmit: props.onSubmit as ResolvedModalProps['onSubmit'],
|
|
320
254
|
onCancel: props.onCancel as ResolvedModalProps['onCancel'],
|
|
321
|
-
fields: Array.isArray(props.fields)
|
|
322
|
-
? cloneFields(props.fields as FormField[])
|
|
323
|
-
: null,
|
|
324
255
|
header: props.header as boolean,
|
|
325
256
|
footer: props.footer as boolean,
|
|
326
257
|
id: props.id as string,
|
|
@@ -333,512 +264,288 @@ function normalizeProps(input: ModalProps): ResolvedModalProps {
|
|
|
333
264
|
let modalScrollLockCount = 0;
|
|
334
265
|
let modalBodyOverflow = '';
|
|
335
266
|
|
|
336
|
-
|
|
267
|
+
interface ModalActions {
|
|
268
|
+
show(): Modal;
|
|
269
|
+
hide(): Modal;
|
|
270
|
+
reset(): Modal;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type Modal = FunctionalComponent<
|
|
337
274
|
ResolvedModalProps,
|
|
338
275
|
ModalState,
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
declare runtime: ModalRuntime;
|
|
343
|
-
declare state: ModalState;
|
|
344
|
-
declare cleanup: Component['cleanup'] & ModalCleanupExtras;
|
|
345
|
-
|
|
346
|
-
constructor(input: ModalProps = {}) {
|
|
347
|
-
const props = normalizeProps(input);
|
|
348
|
-
super(props);
|
|
349
|
-
|
|
350
|
-
this.dom.modal = null;
|
|
351
|
-
this.dom.header = null;
|
|
352
|
-
this.dom.body = null;
|
|
353
|
-
this.dom.footer = null;
|
|
354
|
-
this.dom.form = null;
|
|
355
|
-
this.dom.formContainer = null;
|
|
356
|
-
|
|
357
|
-
this.cleanup.visibility = null;
|
|
358
|
-
this.cleanup.view = null;
|
|
359
|
-
this.cleanup.hideTimer = null;
|
|
360
|
-
|
|
361
|
-
this.runtime.scrollLocked = false;
|
|
362
|
-
this.runtime.visibleApplied = false;
|
|
363
|
-
|
|
364
|
-
this.cache = {
|
|
365
|
-
initial: cloneProps(this.props),
|
|
366
|
-
fieldIds: new Map(),
|
|
367
|
-
previousActiveElement: null,
|
|
368
|
-
formId: `${this.props.id}_form`,
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
this.state = createDeepStore(createModalState(this.props)) as ModalState;
|
|
276
|
+
HTMLElement,
|
|
277
|
+
ModalActions
|
|
278
|
+
>;
|
|
372
279
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}),
|
|
399
|
-
jsx('div', {
|
|
400
|
-
className: className.footer,
|
|
401
|
-
style: { display: this.props.footer ? '' : 'none' },
|
|
402
|
-
ref: (element: HTMLElement) => {
|
|
403
|
-
this.dom.footer = element;
|
|
404
|
-
},
|
|
405
|
-
}),
|
|
406
|
-
];
|
|
407
|
-
|
|
408
|
-
const modal = jsx('div', {
|
|
409
|
-
className: () =>
|
|
410
|
-
joinClasses(
|
|
411
|
-
this.props.className.modal,
|
|
412
|
-
this.props.fullscreen && 'is-fullscreen'
|
|
413
|
-
),
|
|
414
|
-
id,
|
|
415
|
-
role: 'document',
|
|
416
|
-
'data-modal-dialog': id,
|
|
417
|
-
ref: (element: HTMLElement) => {
|
|
418
|
-
this.dom.modal = element;
|
|
419
|
-
},
|
|
420
|
-
children: dialogChildren,
|
|
421
|
-
}) as HTMLElement;
|
|
422
|
-
|
|
423
|
-
const root = createPopup({
|
|
424
|
-
className: className.layout,
|
|
425
|
-
position: this.props.position,
|
|
426
|
-
component: 'modal',
|
|
427
|
-
labelledby: this.props.header ? `${id}_title` : '',
|
|
428
|
-
content: modal,
|
|
429
|
-
});
|
|
430
|
-
if (!this.props.header) {
|
|
431
|
-
root.setAttribute('aria-label', this.props.text.title || 'Modal');
|
|
280
|
+
export function createModal(input: ModalProps = {}): Modal {
|
|
281
|
+
const props = normalizeProps(input);
|
|
282
|
+
const state = createDeepStore(createModalState(props)) as ModalState;
|
|
283
|
+
const cache: ModalCache = {
|
|
284
|
+
initial: cloneProps(props),
|
|
285
|
+
previousActiveElement: null,
|
|
286
|
+
content: null,
|
|
287
|
+
contentSource: null,
|
|
288
|
+
hasContent: false,
|
|
289
|
+
updatedAt: 0,
|
|
290
|
+
};
|
|
291
|
+
const events = new Map<string, () => void>();
|
|
292
|
+
const [motionVisible, setMotionVisible] = createSignal(false);
|
|
293
|
+
const [resolvedContent, setResolvedContent] =
|
|
294
|
+
createSignal<ResolvedModalContent>({ value: null });
|
|
295
|
+
let modal!: Modal;
|
|
296
|
+
let dialog: HTMLElement | null = null;
|
|
297
|
+
let loadingElement: HTMLDivElement | null = null;
|
|
298
|
+
let scrollLocked = false;
|
|
299
|
+
let contentLoadId = 0;
|
|
300
|
+
|
|
301
|
+
const isBusy = (): boolean => state.processing;
|
|
302
|
+
const isContentCacheValid = (source: ModalContent): boolean => {
|
|
303
|
+
if (!props.cache || !cache.hasContent || cache.contentSource !== source) {
|
|
304
|
+
return false;
|
|
432
305
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
this.props.showCancel
|
|
496
|
-
? (jsx('button', {
|
|
497
|
-
type: 'button',
|
|
498
|
-
className: joinClasses(
|
|
499
|
-
this.props.className.button,
|
|
500
|
-
this.props.className.cancelBtn
|
|
501
|
-
),
|
|
502
|
-
'data-action': 'cancel',
|
|
503
|
-
disabled: () => this.isBusy(),
|
|
504
|
-
children: this.props.text.cancel,
|
|
505
|
-
}) as Node)
|
|
506
|
-
: null,
|
|
507
|
-
jsx('button', {
|
|
508
|
-
type: () => (this.state.mode === 'form' ? 'submit' : 'button'),
|
|
509
|
-
className: joinClasses(
|
|
510
|
-
this.props.className.button,
|
|
511
|
-
this.props.className.confirmBtn
|
|
512
|
-
),
|
|
513
|
-
'data-action': () =>
|
|
514
|
-
this.state.mode === 'form' ? 'submit' : 'confirm',
|
|
515
|
-
disabled: () => this.isBusy(),
|
|
516
|
-
children: this.props.text.confirm,
|
|
517
|
-
}) as Node,
|
|
518
|
-
];
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
bodyView(): RenderableContent<Modal> {
|
|
523
|
-
if (this.state.mode === 'form') return this.formView();
|
|
524
|
-
this.destroyForm();
|
|
525
|
-
return this.contentView(this.state.content);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
formView(): HTMLElement {
|
|
529
|
-
return jsx('div', {
|
|
530
|
-
ref: (element: HTMLElement) => {
|
|
531
|
-
this.mountForm(element);
|
|
306
|
+
const ttl = normalizeTtl(props.ttl);
|
|
307
|
+
return !ttl || Date.now() - cache.updatedAt <= ttl;
|
|
308
|
+
};
|
|
309
|
+
const setContentCache = (
|
|
310
|
+
source: ModalContent,
|
|
311
|
+
content: ModalContentResult
|
|
312
|
+
): void => {
|
|
313
|
+
if (!props.cache) return;
|
|
314
|
+
cache.content = content;
|
|
315
|
+
cache.contentSource = source;
|
|
316
|
+
cache.hasContent = true;
|
|
317
|
+
cache.updatedAt = Date.now();
|
|
318
|
+
};
|
|
319
|
+
const clearContentCache = (): void => {
|
|
320
|
+
cache.content = null;
|
|
321
|
+
cache.contentSource = null;
|
|
322
|
+
cache.hasContent = false;
|
|
323
|
+
cache.updatedAt = 0;
|
|
324
|
+
};
|
|
325
|
+
const clearEvents = (): void => {
|
|
326
|
+
for (const dispose of events.values()) dispose();
|
|
327
|
+
events.clear();
|
|
328
|
+
};
|
|
329
|
+
const listen = (
|
|
330
|
+
key: string,
|
|
331
|
+
target: EventTarget,
|
|
332
|
+
type: string,
|
|
333
|
+
listener: EventListener
|
|
334
|
+
): void => {
|
|
335
|
+
events.get(key)?.();
|
|
336
|
+
target.addEventListener(type, listener);
|
|
337
|
+
events.set(key, () => target.removeEventListener(type, listener));
|
|
338
|
+
};
|
|
339
|
+
const lockScroll = (): void => {
|
|
340
|
+
if (scrollLocked) return;
|
|
341
|
+
if (modalScrollLockCount === 0) {
|
|
342
|
+
modalBodyOverflow = document.body.style.overflow;
|
|
343
|
+
document.body.style.overflow = 'hidden';
|
|
344
|
+
}
|
|
345
|
+
modalScrollLockCount += 1;
|
|
346
|
+
scrollLocked = true;
|
|
347
|
+
};
|
|
348
|
+
const unlockScroll = (): void => {
|
|
349
|
+
if (!scrollLocked) return;
|
|
350
|
+
modalScrollLockCount = Math.max(0, modalScrollLockCount - 1);
|
|
351
|
+
if (modalScrollLockCount === 0) {
|
|
352
|
+
document.body.style.overflow = modalBodyOverflow;
|
|
353
|
+
modalBodyOverflow = '';
|
|
354
|
+
}
|
|
355
|
+
scrollLocked = false;
|
|
356
|
+
};
|
|
357
|
+
const restoreFocus = (): void => {
|
|
358
|
+
const target = cache.previousActiveElement;
|
|
359
|
+
cache.previousActiveElement = null;
|
|
360
|
+
if (target && document.contains(target)) target.focus();
|
|
361
|
+
};
|
|
362
|
+
const motion = createMotionGroup(
|
|
363
|
+
createTransition(() => modal.element, {
|
|
364
|
+
keyframes: [{ opacity: 0 }, { opacity: 1 }],
|
|
365
|
+
options: {
|
|
366
|
+
duration: 220,
|
|
367
|
+
easing: 'ease',
|
|
532
368
|
},
|
|
533
|
-
})
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
369
|
+
}),
|
|
370
|
+
createTransition(() => dialog, {
|
|
371
|
+
keyframes: [
|
|
372
|
+
{ opacity: 0, transform: 'scale(0.96)' },
|
|
373
|
+
{ opacity: 1, transform: 'scale(1)' },
|
|
374
|
+
],
|
|
375
|
+
options: {
|
|
376
|
+
duration: 220,
|
|
377
|
+
easing: 'ease',
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
);
|
|
381
|
+
const presence = createPresence({
|
|
382
|
+
elements: () => [modal.element, dialog],
|
|
383
|
+
mount: () => {
|
|
384
|
+
modal.mount(document.body);
|
|
385
|
+
lockScroll();
|
|
386
|
+
},
|
|
387
|
+
activate: () => setMotionVisible(true),
|
|
388
|
+
deactivate: () => setMotionVisible(false),
|
|
389
|
+
motion,
|
|
390
|
+
unmount: () => {
|
|
391
|
+
modal.unmount();
|
|
392
|
+
unlockScroll();
|
|
393
|
+
restoreFocus();
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
const syncLoadingElement = (): void => {
|
|
397
|
+
const busy = state.loading || state.processing;
|
|
398
|
+
if (busy) {
|
|
399
|
+
if (!loadingElement) loadingElement = createLoading();
|
|
400
|
+
if (dialog && loadingElement.parentElement !== dialog) {
|
|
401
|
+
dialog.append(loadingElement);
|
|
548
402
|
}
|
|
549
403
|
return;
|
|
550
404
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
id: this.cache.formId,
|
|
559
|
-
fields: this.state.fields || [],
|
|
560
|
-
buttons: false,
|
|
561
|
-
onSubmit: (data) => this.handleFormSubmit(data),
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
destroyForm(): void {
|
|
566
|
-
this.dom.form?.destroy();
|
|
567
|
-
this.dom.form = null;
|
|
568
|
-
this.dom.formContainer = null;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
contentView(content: ModalContent): Node[] {
|
|
572
|
-
return normalizeContentNodes(content, this);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
bindReactiveLoading(): void {
|
|
576
|
-
let loading: HTMLElement | null = null;
|
|
577
|
-
|
|
578
|
-
createEffect(() => {
|
|
579
|
-
if (this.state.loading && !loading) {
|
|
580
|
-
loading = createLoading();
|
|
581
|
-
this.dom.modal?.appendChild(loading);
|
|
582
|
-
} else if (!this.state.loading && loading) {
|
|
583
|
-
loading.remove();
|
|
584
|
-
loading = null;
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
onCleanup(() => {
|
|
589
|
-
loading?.remove();
|
|
590
|
-
loading = null;
|
|
405
|
+
loadingElement?.remove();
|
|
406
|
+
loadingElement = null;
|
|
407
|
+
};
|
|
408
|
+
const setProcessing = (value: boolean): void => {
|
|
409
|
+
if (state.processing === value) return;
|
|
410
|
+
flushSync(() => {
|
|
411
|
+
state.processing = value;
|
|
591
412
|
});
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
413
|
+
};
|
|
414
|
+
const finishProcessing = (): void => {
|
|
415
|
+
if (state.visible && presence.phase !== 'leaving') {
|
|
416
|
+
setProcessing(false);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const loadContent = (source: ModalContent = state.content): void => {
|
|
420
|
+
if (isContentCacheValid(source)) {
|
|
421
|
+
contentLoadId += 1;
|
|
422
|
+
flushSync(() => {
|
|
423
|
+
setResolvedContent({ value: cache.content });
|
|
424
|
+
state.loading = false;
|
|
603
425
|
});
|
|
604
|
-
return dispose;
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
applyVisibility(visible: boolean): void {
|
|
609
|
-
if (visible === this.runtime.visibleApplied) return;
|
|
610
|
-
if (visible) {
|
|
611
|
-
this.showFromState();
|
|
612
426
|
return;
|
|
613
427
|
}
|
|
614
|
-
this.hideFromState();
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
showFromState(): void {
|
|
618
|
-
if (this.runtime.destroyed) {
|
|
619
|
-
throw new Error('Modal: The current instance has been destroyed.');
|
|
620
|
-
}
|
|
621
428
|
|
|
622
|
-
|
|
623
|
-
|
|
429
|
+
const loadId = ++contentLoadId;
|
|
430
|
+
if (typeof source !== 'function') {
|
|
431
|
+
clearContentCache();
|
|
432
|
+
flushSync(() => {
|
|
433
|
+
setResolvedContent({ value: source });
|
|
434
|
+
state.loading = false;
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
624
437
|
}
|
|
625
438
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (this.dom.root && !this.dom.root.parentNode)
|
|
637
|
-
document.body.appendChild(this.dom.root);
|
|
638
|
-
this.lockScroll();
|
|
639
|
-
this.runtime.visibleApplied = true;
|
|
640
|
-
|
|
641
|
-
this.bindEvents(this.dom.root);
|
|
642
|
-
this.focusFirst();
|
|
643
|
-
|
|
644
|
-
void onShown?.(this);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
hideFromState(): void {
|
|
648
|
-
if (!this.runtime.visibleApplied || !this.dom.root) return;
|
|
649
|
-
|
|
650
|
-
const { onHide, onHidden } = this.props;
|
|
651
|
-
void onHide?.(this);
|
|
652
|
-
|
|
653
|
-
this.runtime.visibleApplied = false;
|
|
654
|
-
this.clearEvents();
|
|
655
|
-
|
|
656
|
-
flushSync(() => {
|
|
657
|
-
this.state.loading = false;
|
|
658
|
-
this.state.extraData = null;
|
|
659
|
-
this.state.data = null;
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
if (this.dom.modal) {
|
|
663
|
-
this.dom.modal.style.transition = `opacity ${HIDE_DURATION}ms ease-out, transform ${HIDE_DURATION}ms ease-out`;
|
|
664
|
-
this.dom.modal.style.opacity = '0';
|
|
665
|
-
this.dom.modal.style.transform = 'scale(0.3)';
|
|
439
|
+
let result: ModalContentResult | Promise<ModalContentResult>;
|
|
440
|
+
try {
|
|
441
|
+
result = source(modal);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error('Modal.content error:', error);
|
|
444
|
+
flushSync(() => {
|
|
445
|
+
setResolvedContent({ value: null });
|
|
446
|
+
state.loading = false;
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
666
449
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
bindEvents(root: Element | null): void {
|
|
676
|
-
this.clearEvents();
|
|
677
|
-
this.bindOverlayCloseEvent(root);
|
|
678
|
-
this.bindDocumentKeyEvent();
|
|
679
|
-
this.bindInsideEvent();
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
bindOverlayCloseEvent(root: Element | null): void {
|
|
683
|
-
if (!root) return;
|
|
684
|
-
this.cleanup.events.on('bg', root, 'click', (event) => {
|
|
685
|
-
if (this.props.bgClose && event.target === root) {
|
|
686
|
-
this.hide();
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
bindDocumentKeyEvent(): void {
|
|
692
|
-
this.cleanup.events.on('keydown', document, 'keydown', (event) => {
|
|
693
|
-
if (!this.state.visible || !(event instanceof KeyboardEvent)) return;
|
|
694
|
-
|
|
695
|
-
if (event.key === 'Escape' && this.props.escClose) {
|
|
696
|
-
event.preventDefault();
|
|
697
|
-
this.hide();
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (event.key === 'Tab') this.trapFocus(event);
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
bindInsideEvent(): void {
|
|
706
|
-
if (!this.dom.modal) return;
|
|
707
|
-
|
|
708
|
-
this.cleanup.events.on('inside', this.dom.modal, 'click', (event) => {
|
|
709
|
-
const target = event.target instanceof Element ? event.target : null;
|
|
710
|
-
const actionEl = target?.closest<HTMLElement>('[data-action]');
|
|
711
|
-
if (!actionEl || !this.dom.modal?.contains(actionEl)) return;
|
|
712
|
-
|
|
713
|
-
const action = actionEl.dataset.action;
|
|
714
|
-
if (action === 'cancel' || action === 'close') {
|
|
715
|
-
void this.handleCancel();
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if (action === 'submit') {
|
|
720
|
-
this.requestSubmit();
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
if (action === 'confirm') {
|
|
725
|
-
if (this.state.mode === 'form') {
|
|
726
|
-
this.requestSubmit();
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
void this.handleConfirm();
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
clearEvents(): void {
|
|
735
|
-
this.cleanup.events.clear();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
requestSubmit(): void {
|
|
739
|
-
if (this.state.mode !== 'form' || !this.dom.form) {
|
|
740
|
-
void this.handleConfirm();
|
|
450
|
+
if (!isPromiseLike<ModalContentResult>(result)) {
|
|
451
|
+
setContentCache(source, result);
|
|
452
|
+
flushSync(() => {
|
|
453
|
+
setResolvedContent({ value: result });
|
|
454
|
+
state.loading = false;
|
|
455
|
+
});
|
|
741
456
|
return;
|
|
742
457
|
}
|
|
743
458
|
|
|
744
|
-
this.dom.form.requestSubmit();
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async handleFormSubmit(formData: FormDataRecord): Promise<void> {
|
|
748
|
-
if (this.isBusy()) return;
|
|
749
|
-
|
|
750
|
-
const data = mergeExtraData(formData, this.state.extraData);
|
|
751
|
-
|
|
752
459
|
flushSync(() => {
|
|
753
|
-
|
|
460
|
+
state.loading = true;
|
|
461
|
+
setResolvedContent({ value: null });
|
|
754
462
|
});
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
463
|
+
void Promise.resolve(result)
|
|
464
|
+
.then((content) => {
|
|
465
|
+
if (modal.runtime.destroyed || loadId !== contentLoadId) return;
|
|
466
|
+
setContentCache(source, content);
|
|
467
|
+
flushSync(() => {
|
|
468
|
+
setResolvedContent({ value: content });
|
|
469
|
+
});
|
|
470
|
+
})
|
|
471
|
+
.catch((error) => {
|
|
472
|
+
if (modal.runtime.destroyed || loadId !== contentLoadId) return;
|
|
473
|
+
console.error('Modal.content error:', error);
|
|
474
|
+
flushSync(() => {
|
|
475
|
+
setResolvedContent({ value: null });
|
|
476
|
+
});
|
|
477
|
+
})
|
|
478
|
+
.finally(() => {
|
|
479
|
+
if (!modal.runtime.destroyed && loadId === contentLoadId) {
|
|
480
|
+
flushSync(() => {
|
|
481
|
+
state.loading = false;
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
const handleConfirm = (): void => {
|
|
487
|
+
if (isBusy()) return;
|
|
488
|
+
let result: void | Promise<void> | undefined;
|
|
766
489
|
try {
|
|
767
|
-
|
|
490
|
+
result = props.onConfirm?.(modal);
|
|
768
491
|
} catch (error) {
|
|
769
492
|
console.error('Modal.onConfirm error:', error);
|
|
770
|
-
|
|
771
|
-
if (this.state) {
|
|
772
|
-
flushSync(() => {
|
|
773
|
-
this.state.processing = false;
|
|
774
|
-
});
|
|
775
|
-
}
|
|
493
|
+
return;
|
|
776
494
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
495
|
+
if (!isPromiseLike<void>(result)) return;
|
|
496
|
+
setProcessing(true);
|
|
497
|
+
void Promise.resolve(result)
|
|
498
|
+
.catch((error) => {
|
|
499
|
+
console.error('Modal.onConfirm error:', error);
|
|
500
|
+
})
|
|
501
|
+
.finally(() => {
|
|
502
|
+
if (!modal.runtime.destroyed) finishProcessing();
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
const handleCancel = (): void => {
|
|
506
|
+
if (isBusy()) return;
|
|
507
|
+
let result: void | Promise<void> | undefined;
|
|
786
508
|
try {
|
|
787
|
-
|
|
788
|
-
this.hide();
|
|
509
|
+
result = props.onCancel?.(modal);
|
|
789
510
|
} catch (error) {
|
|
790
511
|
console.error('Modal.onCancel error:', error);
|
|
791
|
-
|
|
792
|
-
if (this.state) {
|
|
793
|
-
flushSync(() => {
|
|
794
|
-
this.state.processing = false;
|
|
795
|
-
});
|
|
796
|
-
}
|
|
512
|
+
return;
|
|
797
513
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if (!this.props.onSubmit) return;
|
|
802
|
-
|
|
803
|
-
flushSync(() => {
|
|
804
|
-
this.state.processing = true;
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
await Promise.resolve(this.props.onSubmit(data, this));
|
|
809
|
-
this.state.extraData = null;
|
|
810
|
-
} catch (error) {
|
|
811
|
-
console.error('Modal.onSubmit error:', error);
|
|
812
|
-
} finally {
|
|
813
|
-
if (this.state) {
|
|
814
|
-
flushSync(() => {
|
|
815
|
-
this.state.processing = false;
|
|
816
|
-
});
|
|
817
|
-
}
|
|
514
|
+
if (!isPromiseLike<void>(result)) {
|
|
515
|
+
modal.hide();
|
|
516
|
+
return;
|
|
818
517
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
)
|
|
518
|
+
setProcessing(true);
|
|
519
|
+
void Promise.resolve(result)
|
|
520
|
+
.then(() => {
|
|
521
|
+
if (modal.runtime.destroyed) return;
|
|
522
|
+
modal.hide();
|
|
523
|
+
})
|
|
524
|
+
.catch((error) => {
|
|
525
|
+
console.error('Modal.onCancel error:', error);
|
|
526
|
+
})
|
|
527
|
+
.finally(() => {
|
|
528
|
+
if (!modal.runtime.destroyed) {
|
|
529
|
+
finishProcessing();
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
const trapFocus = (event: KeyboardEvent): void => {
|
|
534
|
+
if (!dialog) return;
|
|
535
|
+
const focusable = all<HTMLElement>(
|
|
536
|
+
'a[href], button:not([disabled]):not([data-action=close]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
537
|
+
dialog
|
|
828
538
|
).filter(
|
|
829
539
|
(element) =>
|
|
830
540
|
element.offsetParent !== null || element === document.activeElement
|
|
831
541
|
);
|
|
832
|
-
|
|
833
|
-
if (focusable.length === 0) {
|
|
542
|
+
if (!focusable.length) {
|
|
834
543
|
event.preventDefault();
|
|
835
|
-
|
|
544
|
+
dialog.focus();
|
|
836
545
|
return;
|
|
837
546
|
}
|
|
838
|
-
|
|
839
547
|
const first = focusable[0];
|
|
840
548
|
const last = focusable[focusable.length - 1];
|
|
841
|
-
|
|
842
549
|
if (event.shiftKey && document.activeElement === first) {
|
|
843
550
|
event.preventDefault();
|
|
844
551
|
last?.focus();
|
|
@@ -846,192 +553,248 @@ class ModalComponent extends Component<
|
|
|
846
553
|
event.preventDefault();
|
|
847
554
|
first?.focus();
|
|
848
555
|
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const focusRoot = this.dom.form?.root || this.dom.modal;
|
|
854
|
-
const firstFocusable = q<HTMLElement>(
|
|
556
|
+
};
|
|
557
|
+
const focusFirst = (): void => {
|
|
558
|
+
if (!dialog) return;
|
|
559
|
+
const first = q<HTMLElement>(
|
|
855
560
|
'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([data-action=close]), [tabindex]:not([tabindex="-1"])',
|
|
856
|
-
|
|
561
|
+
dialog
|
|
857
562
|
);
|
|
858
|
-
if (
|
|
563
|
+
if (first) first.focus();
|
|
859
564
|
else {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
lockScroll(): void {
|
|
866
|
-
if (this.runtime.scrollLocked) return;
|
|
867
|
-
if (modalScrollLockCount === 0) {
|
|
868
|
-
modalBodyOverflow = document.body.style.overflow;
|
|
869
|
-
document.body.style.overflow = 'hidden';
|
|
870
|
-
}
|
|
871
|
-
modalScrollLockCount += 1;
|
|
872
|
-
this.runtime.scrollLocked = true;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
unlockScroll(): void {
|
|
876
|
-
if (!this.runtime.scrollLocked) return;
|
|
877
|
-
modalScrollLockCount = Math.max(0, modalScrollLockCount - 1);
|
|
878
|
-
if (modalScrollLockCount === 0) {
|
|
879
|
-
document.body.style.overflow = modalBodyOverflow;
|
|
880
|
-
modalBodyOverflow = '';
|
|
881
|
-
}
|
|
882
|
-
this.runtime.scrollLocked = false;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
cancelHideTimer(): void {
|
|
886
|
-
if (!this.cleanup.hideTimer) return;
|
|
887
|
-
clearTimeout(this.cleanup.hideTimer);
|
|
888
|
-
this.cleanup.hideTimer = null;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
resetAnimationStyles(): void {
|
|
892
|
-
if (!this.dom.modal) return;
|
|
893
|
-
this.dom.modal.style.removeProperty('transition');
|
|
894
|
-
this.dom.modal.style.removeProperty('opacity');
|
|
895
|
-
this.dom.modal.style.removeProperty('transform');
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
finishHide(onHidden: ResolvedModalProps['onHidden']): void {
|
|
899
|
-
this.cleanup.hideTimer = null;
|
|
900
|
-
this.dom.root?.remove();
|
|
901
|
-
this.resetAnimationStyles();
|
|
902
|
-
this.unlockScroll();
|
|
903
|
-
this.restoreFocus();
|
|
904
|
-
void onHidden?.(this);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
restoreFocus(): void {
|
|
908
|
-
const target = this.cache.previousActiveElement;
|
|
909
|
-
this.cache.previousActiveElement = null;
|
|
910
|
-
if (
|
|
911
|
-
target &&
|
|
912
|
-
typeof target.focus === 'function' &&
|
|
913
|
-
document.contains(target)
|
|
914
|
-
) {
|
|
915
|
-
target.focus();
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
assertActive(method: string): void {
|
|
920
|
-
if (this.runtime.destroyed) {
|
|
921
|
-
throw new Error(
|
|
922
|
-
`Modal.${method}: The current instance has been destroyed.`
|
|
923
|
-
);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
protected override normalizeStatePatch(
|
|
928
|
-
patch: ModalStatePatch
|
|
929
|
-
): ModalStatePatch {
|
|
930
|
-
const nextPatch: ModalStatePatch = { ...patch };
|
|
931
|
-
if (Object.hasOwn(nextPatch, 'fields')) {
|
|
932
|
-
nextPatch.fields = Array.isArray(nextPatch.fields)
|
|
933
|
-
? cloneFields(nextPatch.fields)
|
|
934
|
-
: null;
|
|
565
|
+
dialog.setAttribute('tabindex', '-1');
|
|
566
|
+
dialog.focus();
|
|
935
567
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
)
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
show(): this {
|
|
964
|
-
this.assertActive('show');
|
|
965
|
-
if (!this.dom.root) {
|
|
568
|
+
};
|
|
569
|
+
const bindVisibleEvents = (): void => {
|
|
570
|
+
const root = modal.element;
|
|
571
|
+
if (!root || !dialog) return;
|
|
572
|
+
clearEvents();
|
|
573
|
+
listen('root', root, 'click', (event) => {
|
|
574
|
+
if (props.bgClose && !isBusy() && event.target === root) modal.hide();
|
|
575
|
+
});
|
|
576
|
+
listen('dialog', dialog, 'click', (event) => {
|
|
577
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
578
|
+
const action =
|
|
579
|
+
target?.closest<HTMLElement>('[data-action]')?.dataset.action;
|
|
580
|
+
if (action === 'cancel' || action === 'close') handleCancel();
|
|
581
|
+
else if (action === 'confirm') handleConfirm();
|
|
582
|
+
});
|
|
583
|
+
listen('keyboard', document, 'keydown', (event) => {
|
|
584
|
+
if (!(event instanceof KeyboardEvent) || !state.visible) return;
|
|
585
|
+
if (event.key === 'Escape' && props.escClose && !isBusy()) {
|
|
586
|
+
event.preventDefault();
|
|
587
|
+
modal.hide();
|
|
588
|
+
} else if (event.key === 'Tab') trapFocus(event);
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
const showFromState = (): void => {
|
|
592
|
+
if (presence.phase === 'visible' || presence.phase === 'entering') return;
|
|
593
|
+
if (!modal.element) {
|
|
966
594
|
throw new Error('Modal.show: build() must be called before show().');
|
|
967
595
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
596
|
+
void props.onShow?.(modal);
|
|
597
|
+
cache.previousActiveElement =
|
|
598
|
+
document.activeElement instanceof HTMLElement
|
|
599
|
+
? document.activeElement
|
|
600
|
+
: null;
|
|
601
|
+
const entering = presence.enter();
|
|
602
|
+
bindVisibleEvents();
|
|
603
|
+
focusFirst();
|
|
604
|
+
void entering.then((completed) => {
|
|
605
|
+
if (completed && !modal.runtime.destroyed) void props.onShown?.(modal);
|
|
978
606
|
});
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
data: null,
|
|
995
|
-
extraData: null,
|
|
607
|
+
};
|
|
608
|
+
const hideFromState = (): void => {
|
|
609
|
+
if (presence.phase === 'hidden' || presence.phase === 'leaving') return;
|
|
610
|
+
void props.onHide?.(modal);
|
|
611
|
+
clearEvents();
|
|
612
|
+
contentLoadId += 1;
|
|
613
|
+
void presence.leave().then((completed) => {
|
|
614
|
+
if (completed && !modal.runtime.destroyed) {
|
|
615
|
+
flushSync(() => {
|
|
616
|
+
state.loading = false;
|
|
617
|
+
state.processing = false;
|
|
618
|
+
if (!props.cache) setResolvedContent({ value: null });
|
|
619
|
+
});
|
|
620
|
+
void props.onHidden?.(modal);
|
|
621
|
+
}
|
|
996
622
|
});
|
|
997
|
-
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
protected override onDestroy(): void {
|
|
1001
|
-
const wasVisible = !!this.runtime.visibleApplied;
|
|
1002
|
-
const { onHide, onHidden } = this.props;
|
|
1003
|
-
|
|
1004
|
-
if (wasVisible) void onHide?.(this);
|
|
1005
|
-
|
|
1006
|
-
this.cancelHideTimer();
|
|
1007
|
-
this.clearEvents();
|
|
1008
|
-
this.destroyForm();
|
|
1009
|
-
this.unlockScroll();
|
|
1010
|
-
this.cleanup.visibility?.();
|
|
1011
|
-
this.cleanup.visibility = null;
|
|
1012
|
-
this.cleanup.view?.();
|
|
1013
|
-
this.cleanup.view = null;
|
|
1014
|
-
|
|
1015
|
-
this.dom.root?.remove();
|
|
1016
|
-
if (wasVisible) void onHidden?.(this);
|
|
1017
|
-
|
|
1018
|
-
this.cache = {
|
|
1019
|
-
initial: null,
|
|
1020
|
-
fieldIds: null,
|
|
1021
|
-
previousActiveElement: null,
|
|
1022
|
-
formId: '',
|
|
1023
|
-
};
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
override destroy(): this {
|
|
1027
|
-
if (this.runtime.destroyed) return this;
|
|
1028
|
-
super.destroy();
|
|
1029
|
-
return this;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
623
|
+
};
|
|
1032
624
|
|
|
1033
|
-
|
|
625
|
+
modal = defineComponent({
|
|
626
|
+
name: 'Modal',
|
|
627
|
+
props,
|
|
628
|
+
state,
|
|
629
|
+
actions: {
|
|
630
|
+
show() {
|
|
631
|
+
if (!modal.runtime.built) {
|
|
632
|
+
throw new Error('Modal.show: build() must be called before show().');
|
|
633
|
+
}
|
|
634
|
+
modal.setState('visible', true);
|
|
635
|
+
return modal;
|
|
636
|
+
},
|
|
637
|
+
hide() {
|
|
638
|
+
flushSync(() => {
|
|
639
|
+
state.visible = false;
|
|
640
|
+
});
|
|
641
|
+
return modal;
|
|
642
|
+
},
|
|
643
|
+
reset() {
|
|
644
|
+
modal.setState({
|
|
645
|
+
content: cache.initial.content,
|
|
646
|
+
loading: false,
|
|
647
|
+
processing: false,
|
|
648
|
+
});
|
|
649
|
+
clearContentCache();
|
|
650
|
+
setResolvedContent({ value: null });
|
|
651
|
+
return modal;
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
normalizeStatePatch(patch: ModalStatePatch) {
|
|
655
|
+
return {
|
|
656
|
+
...patch,
|
|
657
|
+
};
|
|
658
|
+
},
|
|
659
|
+
validateStatePatch(patch: ModalStatePatch) {
|
|
660
|
+
validateParam('state', patch, MODAL_UPDATE_RULE, 'Modal.setState');
|
|
661
|
+
for (const key of Object.keys(patch)) {
|
|
662
|
+
if (!Object.hasOwn(MODAL_STATE_SCHEMA, key)) {
|
|
663
|
+
throw new Error(
|
|
664
|
+
`Modal.setState: "${key}" is not a supported state key.`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
validateParam(
|
|
668
|
+
key,
|
|
669
|
+
patch[key as keyof ModalStatePatch],
|
|
670
|
+
MODAL_STATE_SCHEMA[key as keyof typeof MODAL_STATE_SCHEMA],
|
|
671
|
+
'Modal.setState'
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
view: () => {
|
|
676
|
+
dialog = jsx('div', {
|
|
677
|
+
className: joinClasses(
|
|
678
|
+
props.className.modal,
|
|
679
|
+
props.fullscreen ? 'is-fullscreen' : ''
|
|
680
|
+
),
|
|
681
|
+
id: props.id,
|
|
682
|
+
role: 'document',
|
|
683
|
+
'data-modal-dialog': props.id,
|
|
684
|
+
'data-mount': () => (motionVisible() ? 'true' : 'false'),
|
|
685
|
+
children: [
|
|
686
|
+
jsx('div', {
|
|
687
|
+
className: props.className.header,
|
|
688
|
+
hidden: () => !props.header || state.loading,
|
|
689
|
+
children: () =>
|
|
690
|
+
props.header && !state.loading
|
|
691
|
+
? [
|
|
692
|
+
jsx('div', {
|
|
693
|
+
className: props.className.title,
|
|
694
|
+
id: `${props.id}_title`,
|
|
695
|
+
'aria-label': props.text.title,
|
|
696
|
+
children: props.text.title,
|
|
697
|
+
}),
|
|
698
|
+
props.showClose
|
|
699
|
+
? jsx('button', {
|
|
700
|
+
type: 'button',
|
|
701
|
+
className: joinClasses(
|
|
702
|
+
props.className.button,
|
|
703
|
+
props.className.closeBtn
|
|
704
|
+
),
|
|
705
|
+
'data-action': 'close',
|
|
706
|
+
'aria-label': t('Close', locales),
|
|
707
|
+
disabled: isBusy,
|
|
708
|
+
children: icon('close'),
|
|
709
|
+
})
|
|
710
|
+
: null,
|
|
711
|
+
]
|
|
712
|
+
: null,
|
|
713
|
+
}),
|
|
714
|
+
jsx('div', {
|
|
715
|
+
className: props.className.body,
|
|
716
|
+
'data-modal-body': '',
|
|
717
|
+
children: () => {
|
|
718
|
+
return resolvedContent().value;
|
|
719
|
+
},
|
|
720
|
+
}),
|
|
721
|
+
jsx('div', {
|
|
722
|
+
className: props.className.footer,
|
|
723
|
+
hidden: () => !props.footer || state.loading,
|
|
724
|
+
children: () =>
|
|
725
|
+
props.footer && !state.loading
|
|
726
|
+
? [
|
|
727
|
+
props.showCancel
|
|
728
|
+
? jsx('button', {
|
|
729
|
+
type: 'button',
|
|
730
|
+
className: joinClasses(
|
|
731
|
+
props.className.button,
|
|
732
|
+
props.className.cancelBtn
|
|
733
|
+
),
|
|
734
|
+
'data-action': 'cancel',
|
|
735
|
+
disabled: isBusy,
|
|
736
|
+
children: props.text.cancel,
|
|
737
|
+
})
|
|
738
|
+
: null,
|
|
739
|
+
jsx('button', {
|
|
740
|
+
type: 'button',
|
|
741
|
+
className: joinClasses(
|
|
742
|
+
props.className.button,
|
|
743
|
+
props.className.confirmBtn
|
|
744
|
+
),
|
|
745
|
+
'data-action': 'confirm',
|
|
746
|
+
disabled: isBusy,
|
|
747
|
+
children: props.text.confirm,
|
|
748
|
+
}),
|
|
749
|
+
]
|
|
750
|
+
: null,
|
|
751
|
+
}),
|
|
752
|
+
],
|
|
753
|
+
}) as HTMLElement;
|
|
754
|
+
const root = createPopup({
|
|
755
|
+
className: props.className.layout,
|
|
756
|
+
position: props.position,
|
|
757
|
+
component: 'modal',
|
|
758
|
+
labelledby: props.header ? `${props.id}_title` : '',
|
|
759
|
+
content: dialog,
|
|
760
|
+
});
|
|
761
|
+
root.setAttribute('data-mount', 'false');
|
|
762
|
+
if (!props.header)
|
|
763
|
+
root.setAttribute('aria-label', props.text.title || 'Modal');
|
|
764
|
+
createEffect(() => {
|
|
765
|
+
root.setAttribute('data-mount', motionVisible() ? 'true' : 'false');
|
|
766
|
+
});
|
|
767
|
+
createEffect(syncLoadingElement);
|
|
768
|
+
createEffect(() => {
|
|
769
|
+
const visible = state.visible;
|
|
770
|
+
untrack(() => (visible ? showFromState() : hideFromState()));
|
|
771
|
+
});
|
|
772
|
+
createEffect(() => {
|
|
773
|
+
const visible = state.visible;
|
|
774
|
+
const content = state.content;
|
|
775
|
+
untrack(() => {
|
|
776
|
+
if (visible) loadContent(content);
|
|
777
|
+
else {
|
|
778
|
+
contentLoadId += 1;
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
void content;
|
|
782
|
+
});
|
|
783
|
+
return root;
|
|
784
|
+
},
|
|
785
|
+
onDestroy() {
|
|
786
|
+
const wasVisible = presence.phase !== 'hidden';
|
|
787
|
+
if (wasVisible) void props.onHide?.(modal);
|
|
788
|
+
presence.cancel();
|
|
789
|
+
clearEvents();
|
|
790
|
+
unlockScroll();
|
|
791
|
+
restoreFocus();
|
|
792
|
+
loadingElement?.remove();
|
|
793
|
+
loadingElement = null;
|
|
794
|
+
dialog = null;
|
|
795
|
+
if (wasVisible) void props.onHidden?.(modal);
|
|
796
|
+
},
|
|
797
|
+
}) as Modal;
|
|
1034
798
|
|
|
1035
|
-
|
|
1036
|
-
return new ModalComponent(input);
|
|
799
|
+
return modal;
|
|
1037
800
|
}
|