@14ch/svelte-ui 0.0.65 → 0.0.67
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/dist/components/DatepickerCalendar.svelte +5 -1
- package/dist/components/Modal.svelte +13 -2
- package/dist/components/Modal.svelte.d.ts +0 -12
- package/dist/components/Popup.svelte +48 -29
- package/dist/components/PopupMenu.svelte +3 -15
- package/dist/components/SnackbarItem.svelte +19 -14
- package/dist/utils/animation.d.ts +12 -0
- package/dist/utils/animation.js +19 -0
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import 'dayjs/locale/es';
|
|
13
13
|
import 'dayjs/locale/zh-cn';
|
|
14
14
|
import IconButton from './IconButton.svelte';
|
|
15
|
-
import { onMount } from 'svelte';
|
|
15
|
+
import { onMount, onDestroy } from 'svelte';
|
|
16
16
|
import { t } from '../i18n';
|
|
17
17
|
import type { BivariantValueHandler } from '../types/callbackHandlers';
|
|
18
18
|
|
|
@@ -151,6 +151,10 @@
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
onDestroy(() => {
|
|
155
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
156
|
+
});
|
|
157
|
+
|
|
154
158
|
// =========================================================================
|
|
155
159
|
// Effects
|
|
156
160
|
// =========================================================================
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* This prevents state synchronization bugs and ensures consistent behavior.
|
|
14
14
|
*/
|
|
15
|
+
import { tick } from 'svelte';
|
|
15
16
|
import type { Snippet } from 'svelte';
|
|
16
17
|
import type { CloseReason } from '../types/propOptions';
|
|
17
18
|
import { announceOpenClose } from '../utils/accessibility';
|
|
19
|
+
import { waitForAnimations } from '../utils/animation';
|
|
18
20
|
|
|
19
21
|
// =========================================================================
|
|
20
22
|
// Props, States & Constants
|
|
@@ -194,7 +196,6 @@
|
|
|
194
196
|
// 閉じるアニメーションの途中で開き直した場合は close を取り消す
|
|
195
197
|
pendingCloseReason = null;
|
|
196
198
|
dialogRef.classList.add('fade-in');
|
|
197
|
-
dialogRef.removeEventListener('animationend', closeEnd);
|
|
198
199
|
dialogRef.showModal();
|
|
199
200
|
|
|
200
201
|
setTimeout(() => {
|
|
@@ -214,11 +215,21 @@
|
|
|
214
215
|
isOpen = false;
|
|
215
216
|
pendingCloseReason = reason;
|
|
216
217
|
dialogRef.classList.add('fade-out');
|
|
217
|
-
|
|
218
|
+
void finishClose();
|
|
218
219
|
|
|
219
220
|
announceOpenClose(componentType, false, title || ariaLabel || '');
|
|
220
221
|
};
|
|
221
222
|
|
|
223
|
+
/** 閉じるアニメーションの完了を待って後始末する。 */
|
|
224
|
+
const finishClose = async (): Promise<void> => {
|
|
225
|
+
await tick();
|
|
226
|
+
await waitForAnimations(dialogRef);
|
|
227
|
+
|
|
228
|
+
// 閉じるアニメーションの途中で開き直した場合は何もしない
|
|
229
|
+
if (pendingCloseReason === null) return;
|
|
230
|
+
closeEnd();
|
|
231
|
+
};
|
|
232
|
+
|
|
222
233
|
/** @internal Completes the close animation. Called automatically. */
|
|
223
234
|
export const closeEnd = (): void => {
|
|
224
235
|
if (!dialogRef) return;
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 🚨 CRITICAL: DO NOT MANAGE MODAL STATE FROM PARENT COMPONENTS
|
|
3
|
-
*
|
|
4
|
-
* This Modal component manages its own open/closed state internally.
|
|
5
|
-
* Parent components must NEVER create their own Modal state variables.
|
|
6
|
-
*
|
|
7
|
-
* ❌ WRONG: let isModalOpen = $state(false)
|
|
8
|
-
* ✅ RIGHT: Use modalRef.open(), modalRef.close(), modalRef.toggle()
|
|
9
|
-
* ✅ RIGHT: Use onOpen/onClose callbacks for side effects
|
|
10
|
-
*
|
|
11
|
-
* This prevents state synchronization bugs and ensures consistent behavior.
|
|
12
|
-
*/
|
|
13
1
|
import type { Snippet } from 'svelte';
|
|
14
2
|
import type { CloseReason } from '../types/propOptions';
|
|
15
3
|
export type ModalProps = {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { onDestroy, tick, onMount } from 'svelte';
|
|
19
19
|
import { isMobileDevice, disableBodyScroll } from '../utils/mobile';
|
|
20
20
|
import { announceOpenClose } from '../utils/accessibility';
|
|
21
|
+
import { waitForAnimations } from '../utils/animation';
|
|
21
22
|
import { popupManager } from '../utils/popupManager';
|
|
22
23
|
import type { CloseReason, PopupPosition } from '../types/propOptions';
|
|
23
24
|
|
|
@@ -106,6 +107,10 @@
|
|
|
106
107
|
let isMobile: boolean = $state(false);
|
|
107
108
|
let bodyScrollCleanup: (() => void) | undefined = $state();
|
|
108
109
|
let pendingCloseReason: CloseReason = 'explicit';
|
|
110
|
+
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
+
// open()/close() のたびに進め、進行中の終了処理が古い場合に破棄するための番号
|
|
112
|
+
let closeSequence = 0;
|
|
113
|
+
let isClosing = false;
|
|
109
114
|
|
|
110
115
|
// =========================================================================
|
|
111
116
|
// Lifecycle
|
|
@@ -115,6 +120,9 @@
|
|
|
115
120
|
});
|
|
116
121
|
|
|
117
122
|
onDestroy(() => {
|
|
123
|
+
cancelPendingOpen();
|
|
124
|
+
closeSequence++;
|
|
125
|
+
isClosing = false;
|
|
118
126
|
removeEventListenersToClose();
|
|
119
127
|
removeKeyboardListener();
|
|
120
128
|
cleanupMobileFeatures();
|
|
@@ -346,16 +354,25 @@
|
|
|
346
354
|
|
|
347
355
|
const closeOnResize = () => close();
|
|
348
356
|
|
|
357
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
358
|
+
if (!(event.target instanceof Node)) return;
|
|
359
|
+
if (popupRef?.contains(event.target)) return;
|
|
360
|
+
if (anchorElement?.contains(event.target)) return;
|
|
361
|
+
close('outside');
|
|
362
|
+
};
|
|
363
|
+
|
|
349
364
|
const addEventListenersToClose = () => {
|
|
350
365
|
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
351
366
|
window.addEventListener('resize', closeOnResize);
|
|
352
367
|
document.addEventListener('scroll', handleScroll, true);
|
|
368
|
+
document.addEventListener('click', handleClickOutside, true);
|
|
353
369
|
};
|
|
354
370
|
|
|
355
371
|
const removeEventListenersToClose = () => {
|
|
356
372
|
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
357
373
|
window.removeEventListener('resize', closeOnResize);
|
|
358
374
|
document.removeEventListener('scroll', handleScroll, true);
|
|
375
|
+
document.removeEventListener('click', handleClickOutside, true);
|
|
359
376
|
};
|
|
360
377
|
|
|
361
378
|
const addKeyboardListener = () => {
|
|
@@ -382,7 +399,7 @@
|
|
|
382
399
|
}
|
|
383
400
|
};
|
|
384
401
|
|
|
385
|
-
const
|
|
402
|
+
const announceOpened = () => {
|
|
386
403
|
if (!ariaLabel && !ariaLabelledby) return;
|
|
387
404
|
announceOpenClose('Popup', true, ariaLabel);
|
|
388
405
|
};
|
|
@@ -403,6 +420,9 @@
|
|
|
403
420
|
};
|
|
404
421
|
|
|
405
422
|
const closeEnd = () => {
|
|
423
|
+
if (!isClosing) return;
|
|
424
|
+
isClosing = false;
|
|
425
|
+
|
|
406
426
|
popupRef?.hidePopover();
|
|
407
427
|
|
|
408
428
|
if (popupRef) {
|
|
@@ -421,29 +441,19 @@
|
|
|
421
441
|
onClose?.(reason);
|
|
422
442
|
};
|
|
423
443
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
444
|
+
/** 閉じるアニメーションの完了を待って後始末する。 */
|
|
445
|
+
const finishClose = async (sequence: number) => {
|
|
446
|
+
await tick();
|
|
447
|
+
await waitForAnimations(popupRef);
|
|
427
448
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
449
|
+
if (sequence !== closeSequence) return;
|
|
450
|
+
closeEnd();
|
|
451
|
+
};
|
|
431
452
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
setTimeout(() => {
|
|
437
|
-
document.body.addEventListener('click', onClick);
|
|
438
|
-
}, 1);
|
|
439
|
-
return {
|
|
440
|
-
update(newCallbackFunction: () => void) {
|
|
441
|
-
callbackFunction = newCallbackFunction;
|
|
442
|
-
},
|
|
443
|
-
destroy() {
|
|
444
|
-
document.body.removeEventListener('click', onClick);
|
|
445
|
-
}
|
|
446
|
-
};
|
|
453
|
+
const cancelPendingOpen = () => {
|
|
454
|
+
if (openTimer === undefined) return;
|
|
455
|
+
clearTimeout(openTimer);
|
|
456
|
+
openTimer = undefined;
|
|
447
457
|
};
|
|
448
458
|
|
|
449
459
|
/** Opens the popup. */
|
|
@@ -451,10 +461,17 @@
|
|
|
451
461
|
// 他の開いているPopupをすべて閉じる
|
|
452
462
|
popupManager.closeOthers(close);
|
|
453
463
|
|
|
464
|
+
// 閉じるアニメーションの途中で開き直した場合は close を取り消す
|
|
465
|
+
closeSequence++;
|
|
466
|
+
isClosing = false;
|
|
467
|
+
pendingCloseReason = 'explicit';
|
|
468
|
+
|
|
454
469
|
previousActiveElement = document.activeElement as HTMLElement;
|
|
455
470
|
|
|
456
|
-
|
|
457
|
-
|
|
471
|
+
cancelPendingOpen();
|
|
472
|
+
openTimer = setTimeout(async () => {
|
|
473
|
+
openTimer = undefined;
|
|
474
|
+
if (!popupRef?.isConnected) return;
|
|
458
475
|
|
|
459
476
|
if (shouldUseFullscreen && popupRef) {
|
|
460
477
|
// ボトムシート: showPopover()の前に位置を画面下部に設定
|
|
@@ -497,24 +514,29 @@
|
|
|
497
514
|
focusFirstElement();
|
|
498
515
|
}
|
|
499
516
|
|
|
500
|
-
|
|
517
|
+
announceOpened();
|
|
501
518
|
onOpen?.();
|
|
502
519
|
}, 1);
|
|
503
520
|
};
|
|
504
521
|
|
|
505
522
|
/** Closes the popup. */
|
|
506
523
|
export const close = (reason: CloseReason = 'explicit') => {
|
|
524
|
+
cancelPendingOpen();
|
|
525
|
+
// 開いていないPopupのcloseは、close reasonを汚さないよう何もしない
|
|
526
|
+
if (!isOpen && !isClosing && !popupRef?.matches(':popover-open')) return;
|
|
527
|
+
|
|
507
528
|
if (pendingCloseReason === 'explicit') {
|
|
508
529
|
pendingCloseReason = reason;
|
|
509
530
|
}
|
|
510
531
|
isOpen = false;
|
|
532
|
+
isClosing = true;
|
|
511
533
|
removeEventListenersToClose();
|
|
512
534
|
removeKeyboardListener();
|
|
513
535
|
cleanupMobileFeatures();
|
|
514
536
|
popupManager.unregister(close);
|
|
515
537
|
// アニメーション完了後に閉じる(fullscreen/通常共通)
|
|
516
|
-
popupRef?.addEventListener('animationend', closeEnd, { once: true });
|
|
517
538
|
// onCloseはアニメーション完了後に呼ぶ(closeEndで実行)
|
|
539
|
+
void finishClose(++closeSequence);
|
|
518
540
|
};
|
|
519
541
|
|
|
520
542
|
/** Toggles between open and closed. */
|
|
@@ -552,9 +574,6 @@
|
|
|
552
574
|
aria-modal={undefined}
|
|
553
575
|
id={popupId}
|
|
554
576
|
data-testid="popup"
|
|
555
|
-
use:clickOutside={() => {
|
|
556
|
-
if (isOpen) close('outside');
|
|
557
|
-
}}
|
|
558
577
|
>
|
|
559
578
|
{@render children()}
|
|
560
579
|
</div>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
import Popup from './Popup.svelte';
|
|
5
5
|
import Icon from './Icon.svelte';
|
|
6
|
+
import { announceToScreenReader } from '../utils/accessibility';
|
|
6
7
|
import type { MenuItem } from '../types/menuItem';
|
|
7
8
|
|
|
8
9
|
import type { SvelteComponent } from 'svelte';
|
|
@@ -167,26 +168,13 @@
|
|
|
167
168
|
activeIndex = index;
|
|
168
169
|
};
|
|
169
170
|
|
|
170
|
-
const announceMenuOpened = () => {
|
|
171
|
-
const announcement = document.createElement('div');
|
|
172
|
-
announcement.setAttribute('aria-live', 'polite');
|
|
173
|
-
announcement.setAttribute('aria-atomic', 'true');
|
|
174
|
-
announcement.className = 'sr-only';
|
|
175
|
-
announcement.textContent = `${ariaLabel} opened with ${actionableItems.length} items`;
|
|
176
|
-
document.body.appendChild(announcement);
|
|
177
|
-
|
|
178
|
-
setTimeout(() => {
|
|
179
|
-
document.body.removeChild(announcement);
|
|
180
|
-
}, 1000);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
171
|
const handlePopupOpen = () => {
|
|
184
172
|
activeIndex = -1; // 初期状態ではハイライトなし
|
|
185
173
|
tick().then(() => {
|
|
186
174
|
menuContainerRef?.focus();
|
|
187
175
|
});
|
|
188
176
|
|
|
189
|
-
|
|
177
|
+
announceToScreenReader(`${ariaLabel} opened with ${actionableItems.length} items`);
|
|
190
178
|
};
|
|
191
179
|
|
|
192
180
|
const handlePopupClose = (reason: 'escape' | 'outside' | 'explicit') => {
|
|
@@ -255,7 +243,7 @@
|
|
|
255
243
|
onkeydown={handleKeyDown}
|
|
256
244
|
>
|
|
257
245
|
<ul class="popup-menu__list" role="none">
|
|
258
|
-
{#each menuItems as item, index}
|
|
246
|
+
{#each menuItems as item, index (item === 'separator' ? `separator-${index}` : item)}
|
|
259
247
|
<li class="popup-menu__item" role="none">
|
|
260
248
|
{#if item === 'separator'}
|
|
261
249
|
<div class="popup-menu__separator" role="separator" aria-orientation="horizontal"></div>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<!-- SnackbarItem.svelte -->
|
|
2
2
|
|
|
3
3
|
<script lang="ts">
|
|
4
|
-
import { onMount } from 'svelte';
|
|
4
|
+
import { onMount, onDestroy, tick } from 'svelte';
|
|
5
5
|
import type { Snippet } from 'svelte';
|
|
6
6
|
import { snackbar } from '../utils/snackbar.svelte';
|
|
7
|
+
import { waitForAnimations } from '../utils/animation';
|
|
7
8
|
import type { IconVariant } from '../types/icon';
|
|
8
9
|
import type { SnackbarType, SnackbarVariant, SnackbarPosition } from '../types/propOptions';
|
|
9
10
|
import Icon from './Icon.svelte';
|
|
@@ -89,15 +90,23 @@
|
|
|
89
90
|
onMount(() => {
|
|
90
91
|
if (duration > 0) {
|
|
91
92
|
timeoutId = setTimeout(() => {
|
|
93
|
+
timeoutId = null;
|
|
92
94
|
handleClose();
|
|
93
95
|
}, duration);
|
|
94
96
|
}
|
|
95
97
|
});
|
|
96
98
|
|
|
99
|
+
onDestroy(() => {
|
|
100
|
+
if (timeoutId !== null) {
|
|
101
|
+
clearTimeout(timeoutId);
|
|
102
|
+
timeoutId = null;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
97
106
|
// =========================================================================
|
|
98
107
|
// Methods
|
|
99
108
|
// =========================================================================
|
|
100
|
-
const handleClose = () => {
|
|
109
|
+
const handleClose = async () => {
|
|
101
110
|
// アニメーション開始前に、他のSnackbarの位置を測定
|
|
102
111
|
if (snackbarRef) {
|
|
103
112
|
// 現在の要素の位置と親コンテナを取得
|
|
@@ -119,21 +128,17 @@
|
|
|
119
128
|
);
|
|
120
129
|
|
|
121
130
|
// CSSカスタムプロパティが確実に設定されるまで少し待つ
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
snackbarRef
|
|
128
|
-
'animationend',
|
|
129
|
-
() => {
|
|
130
|
-
snackbar.remove(id);
|
|
131
|
-
},
|
|
132
|
-
{ once: true }
|
|
133
|
-
);
|
|
131
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
132
|
+
visible = false;
|
|
133
|
+
|
|
134
|
+
// アニメーション完了後にSnackbar削除
|
|
135
|
+
await tick();
|
|
136
|
+
await waitForAnimations(snackbarRef);
|
|
134
137
|
} else {
|
|
135
138
|
visible = false;
|
|
136
139
|
}
|
|
140
|
+
|
|
141
|
+
snackbar.remove(id);
|
|
137
142
|
};
|
|
138
143
|
|
|
139
144
|
const handleAction = () => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation utilities
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 要素で実行中のアニメーションの完了を待つ。
|
|
6
|
+
*
|
|
7
|
+
* animationend イベントに依存すると、アニメーションが動かない状況
|
|
8
|
+
* (アニメーション無効化・要素が非表示・テスト環境など)でイベントが発火せず、
|
|
9
|
+
* 閉じる処理の後始末が永久に実行されないままになる。実際の Animation を待つことで、
|
|
10
|
+
* アニメーションが無い場合は即座に解決する。
|
|
11
|
+
*/
|
|
12
|
+
export declare const waitForAnimations: (element: Element | null | undefined) => Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation utilities
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 要素で実行中のアニメーションの完了を待つ。
|
|
6
|
+
*
|
|
7
|
+
* animationend イベントに依存すると、アニメーションが動かない状況
|
|
8
|
+
* (アニメーション無効化・要素が非表示・テスト環境など)でイベントが発火せず、
|
|
9
|
+
* 閉じる処理の後始末が永久に実行されないままになる。実際の Animation を待つことで、
|
|
10
|
+
* アニメーションが無い場合は即座に解決する。
|
|
11
|
+
*/
|
|
12
|
+
export const waitForAnimations = async (element) => {
|
|
13
|
+
if (!element?.isConnected)
|
|
14
|
+
return;
|
|
15
|
+
const animations = element.getAnimations();
|
|
16
|
+
if (animations.length === 0)
|
|
17
|
+
return;
|
|
18
|
+
await Promise.allSettled(animations.map((animation) => animation.finished));
|
|
19
|
+
};
|