@imjp/writenex-astro 0.1.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 +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- package/src/types/version.ts +117 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Skip Link component for keyboard navigation
|
|
3
|
+
*
|
|
4
|
+
* A visually hidden link that becomes visible on focus, allowing keyboard
|
|
5
|
+
* users to skip repetitive navigation and jump directly to main content.
|
|
6
|
+
* This is a critical accessibility feature for WCAG 2.1 AA compliance.
|
|
7
|
+
*
|
|
8
|
+
* @module @writenex/astro/client/components/SkipLink
|
|
9
|
+
* @see {@link https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback } from "react";
|
|
13
|
+
import "./SkipLink.css";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Props for the SkipLink component
|
|
17
|
+
*/
|
|
18
|
+
export interface SkipLinkProps {
|
|
19
|
+
/** Target element ID to skip to (without the # prefix) */
|
|
20
|
+
targetId: string;
|
|
21
|
+
/** Link text for screen readers */
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Skip link component for keyboard accessibility
|
|
27
|
+
*
|
|
28
|
+
* Renders a visually hidden link that becomes visible when focused.
|
|
29
|
+
* When activated, it moves focus to the target element specified by targetId.
|
|
30
|
+
*
|
|
31
|
+
* @component
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <SkipLink targetId="main-content">Skip to main content</SkipLink>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function SkipLink({
|
|
38
|
+
targetId,
|
|
39
|
+
children,
|
|
40
|
+
}: SkipLinkProps): React.ReactElement {
|
|
41
|
+
/**
|
|
42
|
+
* Handle click to move focus to target element
|
|
43
|
+
*/
|
|
44
|
+
const handleClick = useCallback(
|
|
45
|
+
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
|
|
48
|
+
const targetElement = document.getElementById(targetId);
|
|
49
|
+
if (targetElement) {
|
|
50
|
+
// Make the target focusable if it isn't already
|
|
51
|
+
if (!targetElement.hasAttribute("tabindex")) {
|
|
52
|
+
targetElement.setAttribute("tabindex", "-1");
|
|
53
|
+
}
|
|
54
|
+
targetElement.focus();
|
|
55
|
+
// Scroll the element into view
|
|
56
|
+
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[targetId]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<a href={`#${targetId}`} className="wn-skip-link" onClick={handleClick}>
|
|
64
|
+
{children}
|
|
65
|
+
</a>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview UnsavedChangesModal styles
|
|
3
|
+
*
|
|
4
|
+
* Modal styling for unsaved changes confirmation dialog.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* ============================================================================
|
|
8
|
+
MODAL OVERLAY
|
|
9
|
+
============================================================================ */
|
|
10
|
+
|
|
11
|
+
.wn-unsaved-modal-overlay {
|
|
12
|
+
position: fixed;
|
|
13
|
+
inset: 0;
|
|
14
|
+
z-index: var(--wn-z-modal);
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
background-color: var(--wn-backdrop);
|
|
19
|
+
animation: wn-unsaved-modal-fadeIn var(--wn-transition-fast) ease-out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@keyframes wn-unsaved-modal-fadeIn {
|
|
23
|
+
from {
|
|
24
|
+
opacity: 0;
|
|
25
|
+
}
|
|
26
|
+
to {
|
|
27
|
+
opacity: 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ============================================================================
|
|
32
|
+
MODAL CONTAINER
|
|
33
|
+
============================================================================ */
|
|
34
|
+
|
|
35
|
+
.wn-unsaved-modal {
|
|
36
|
+
width: 100%;
|
|
37
|
+
max-width: var(--wn-modal-sm);
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
border-radius: var(--wn-radius-lg);
|
|
40
|
+
border: 1px solid var(--wn-zinc-700);
|
|
41
|
+
background-color: var(--wn-zinc-900);
|
|
42
|
+
animation: wn-unsaved-modal-zoomIn var(--wn-transition-fast) ease-out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@keyframes wn-unsaved-modal-zoomIn {
|
|
46
|
+
from {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
transform: scale(0.95) translateY(-10px);
|
|
49
|
+
}
|
|
50
|
+
to {
|
|
51
|
+
opacity: 1;
|
|
52
|
+
transform: scale(1) translateY(0);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ============================================================================
|
|
57
|
+
MODAL HEADER
|
|
58
|
+
============================================================================ */
|
|
59
|
+
|
|
60
|
+
.wn-unsaved-modal-header {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: space-between;
|
|
64
|
+
padding: var(--wn-space-5) var(--wn-space-6);
|
|
65
|
+
border-bottom: 1px solid var(--wn-zinc-700);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.wn-unsaved-modal-header-content {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--wn-space-4);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.wn-unsaved-modal-icon {
|
|
75
|
+
color: var(--wn-warning-500);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.wn-unsaved-modal-title {
|
|
79
|
+
font-size: var(--wn-font-md);
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
color: var(--wn-zinc-50);
|
|
82
|
+
margin: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.wn-unsaved-modal-close {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
width: var(--wn-icon-btn-md);
|
|
90
|
+
height: var(--wn-icon-btn-md);
|
|
91
|
+
padding: 0;
|
|
92
|
+
border: none;
|
|
93
|
+
border-radius: var(--wn-radius-md);
|
|
94
|
+
background-color: transparent;
|
|
95
|
+
color: var(--wn-zinc-400);
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
transition:
|
|
98
|
+
background-color var(--wn-transition-fast),
|
|
99
|
+
color var(--wn-transition-fast);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.wn-unsaved-modal-close:hover:not(:disabled) {
|
|
103
|
+
background-color: var(--wn-overlay-10);
|
|
104
|
+
color: var(--wn-zinc-50);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.wn-unsaved-modal-close:disabled {
|
|
108
|
+
opacity: 0.5;
|
|
109
|
+
cursor: not-allowed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ============================================================================
|
|
113
|
+
MODAL BODY
|
|
114
|
+
============================================================================ */
|
|
115
|
+
|
|
116
|
+
.wn-unsaved-modal-body {
|
|
117
|
+
padding: var(--wn-space-6);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.wn-unsaved-modal-text {
|
|
121
|
+
font-size: var(--wn-font-base);
|
|
122
|
+
color: var(--wn-zinc-400);
|
|
123
|
+
margin: 0;
|
|
124
|
+
line-height: 1.5;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ============================================================================
|
|
128
|
+
MODAL FOOTER
|
|
129
|
+
============================================================================ */
|
|
130
|
+
|
|
131
|
+
.wn-unsaved-modal-footer {
|
|
132
|
+
display: flex;
|
|
133
|
+
justify-content: flex-end;
|
|
134
|
+
gap: var(--wn-space-4);
|
|
135
|
+
padding: var(--wn-space-5) var(--wn-space-6);
|
|
136
|
+
border-top: 1px solid var(--wn-zinc-700);
|
|
137
|
+
background-color: var(--wn-overlay-light-10);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Buttons */
|
|
141
|
+
.wn-unsaved-modal-btn {
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
gap: var(--wn-space-3);
|
|
146
|
+
padding: var(--wn-space-3) var(--wn-space-5);
|
|
147
|
+
border-radius: var(--wn-radius-md);
|
|
148
|
+
font-size: var(--wn-font-base);
|
|
149
|
+
font-weight: 500;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
transition:
|
|
152
|
+
background-color var(--wn-transition-fast),
|
|
153
|
+
color var(--wn-transition-fast),
|
|
154
|
+
border-color var(--wn-transition-fast);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.wn-unsaved-modal-btn:disabled {
|
|
158
|
+
opacity: 0.5;
|
|
159
|
+
cursor: not-allowed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Don't Save button - subtle danger styling */
|
|
163
|
+
.wn-unsaved-modal-btn--secondary {
|
|
164
|
+
background-color: transparent;
|
|
165
|
+
border: 1px solid var(--wn-zinc-700);
|
|
166
|
+
color: var(--wn-error-400);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.wn-unsaved-modal-btn--secondary:hover:not(:disabled) {
|
|
170
|
+
background-color: var(--wn-error-alpha-10);
|
|
171
|
+
color: #fca5a5;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* Save button - primary action */
|
|
175
|
+
.wn-unsaved-modal-btn--primary {
|
|
176
|
+
background-color: var(--wn-brand-500);
|
|
177
|
+
border: 1px solid var(--wn-brand-500);
|
|
178
|
+
color: white;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.wn-unsaved-modal-btn--primary:hover:not(:disabled) {
|
|
182
|
+
background-color: var(--wn-brand-600);
|
|
183
|
+
border-color: var(--wn-brand-600);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ============================================================================
|
|
187
|
+
LIGHT MODE OVERRIDES
|
|
188
|
+
============================================================================ */
|
|
189
|
+
|
|
190
|
+
.wn-light .wn-unsaved-modal-overlay {
|
|
191
|
+
background-color: var(--wn-backdrop-light);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.wn-light .wn-unsaved-modal {
|
|
195
|
+
border-color: var(--wn-zinc-200);
|
|
196
|
+
background-color: #fff;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.wn-light .wn-unsaved-modal-header {
|
|
200
|
+
border-bottom-color: var(--wn-zinc-200);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.wn-light .wn-unsaved-modal-title {
|
|
204
|
+
color: var(--wn-zinc-900);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.wn-light .wn-unsaved-modal-close {
|
|
208
|
+
color: var(--wn-zinc-500);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.wn-light .wn-unsaved-modal-close:hover:not(:disabled) {
|
|
212
|
+
background-color: var(--wn-overlay-light-5);
|
|
213
|
+
color: var(--wn-zinc-900);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.wn-light .wn-unsaved-modal-text {
|
|
217
|
+
color: var(--wn-zinc-600);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.wn-light .wn-unsaved-modal-footer {
|
|
221
|
+
border-top-color: var(--wn-zinc-200);
|
|
222
|
+
background-color: var(--wn-zinc-50);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.wn-light .wn-unsaved-modal-btn--secondary {
|
|
226
|
+
border-color: var(--wn-zinc-200);
|
|
227
|
+
color: var(--wn-error-600);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.wn-light .wn-unsaved-modal-btn--secondary:hover:not(:disabled) {
|
|
231
|
+
background-color: var(--wn-error-alpha-10);
|
|
232
|
+
color: #b91c1c;
|
|
233
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unsaved changes confirmation modal
|
|
3
|
+
*
|
|
4
|
+
* Modal dialog that prompts users when they attempt to navigate away
|
|
5
|
+
* from content with unsaved changes. Provides options to save or discard.
|
|
6
|
+
* Cancel is available via X button, clicking outside, or pressing Escape.
|
|
7
|
+
* Includes focus trap for accessibility compliance.
|
|
8
|
+
*
|
|
9
|
+
* @module @writenex/astro/client/components/UnsavedChangesModal
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
13
|
+
import { X, AlertTriangle } from "lucide-react";
|
|
14
|
+
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
15
|
+
import "./UnsavedChangesModal.css";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Props for UnsavedChangesModal component
|
|
19
|
+
*/
|
|
20
|
+
interface UnsavedChangesModalProps {
|
|
21
|
+
/** Whether the modal is open */
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
/** Callback to close the modal (cancel action) */
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
/** Callback when user chooses to discard changes and continue */
|
|
26
|
+
onDiscard: () => void;
|
|
27
|
+
/** Callback when user chooses to save and continue */
|
|
28
|
+
onSave: () => void;
|
|
29
|
+
/** Whether save is in progress */
|
|
30
|
+
isSaving?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Modal dialog for unsaved changes confirmation
|
|
35
|
+
*
|
|
36
|
+
* Features:
|
|
37
|
+
* - Two action buttons: Don't Save (discard), Save
|
|
38
|
+
* - Cancel via X button, click outside, or Escape key
|
|
39
|
+
* - Loading state during save
|
|
40
|
+
* - Consistent styling with design system
|
|
41
|
+
*
|
|
42
|
+
* @component
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <UnsavedChangesModal
|
|
46
|
+
* isOpen={showUnsavedModal}
|
|
47
|
+
* onClose={() => setShowUnsavedModal(false)}
|
|
48
|
+
* onDiscard={handleDiscard}
|
|
49
|
+
* onSave={handleSave}
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function UnsavedChangesModal({
|
|
54
|
+
isOpen,
|
|
55
|
+
onClose,
|
|
56
|
+
onDiscard,
|
|
57
|
+
onSave,
|
|
58
|
+
isSaving = false,
|
|
59
|
+
}: UnsavedChangesModalProps): React.ReactElement | null {
|
|
60
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
61
|
+
const discardButtonRef = useRef<HTMLButtonElement>(null);
|
|
62
|
+
|
|
63
|
+
// Store the trigger element when modal opens
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (isOpen) {
|
|
66
|
+
triggerRef.current = document.activeElement as HTMLElement;
|
|
67
|
+
}
|
|
68
|
+
}, [isOpen]);
|
|
69
|
+
|
|
70
|
+
// Focus trap for accessibility
|
|
71
|
+
const { containerRef } = useFocusTrap({
|
|
72
|
+
enabled: isOpen,
|
|
73
|
+
onEscape: isSaving ? undefined : onClose,
|
|
74
|
+
returnFocusTo: triggerRef.current,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Focus first button when modal opens
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (isOpen) {
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
discardButtonRef.current?.focus();
|
|
82
|
+
}, 50);
|
|
83
|
+
}
|
|
84
|
+
}, [isOpen]);
|
|
85
|
+
|
|
86
|
+
const handleOverlayClick = useCallback(
|
|
87
|
+
(e: React.MouseEvent) => {
|
|
88
|
+
if (e.target === e.currentTarget && !isSaving) {
|
|
89
|
+
onClose();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
[onClose, isSaving]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (!isOpen) return null;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className="wn-unsaved-modal-overlay"
|
|
100
|
+
onClick={handleOverlayClick}
|
|
101
|
+
role="presentation"
|
|
102
|
+
>
|
|
103
|
+
<div
|
|
104
|
+
ref={containerRef}
|
|
105
|
+
className="wn-unsaved-modal"
|
|
106
|
+
role="alertdialog"
|
|
107
|
+
aria-modal="true"
|
|
108
|
+
aria-labelledby="unsaved-modal-title"
|
|
109
|
+
aria-describedby="unsaved-modal-description"
|
|
110
|
+
>
|
|
111
|
+
{/* Header */}
|
|
112
|
+
<div className="wn-unsaved-modal-header">
|
|
113
|
+
<div className="wn-unsaved-modal-header-content">
|
|
114
|
+
<AlertTriangle size={18} className="wn-unsaved-modal-icon" />
|
|
115
|
+
<h2 id="unsaved-modal-title" className="wn-unsaved-modal-title">
|
|
116
|
+
Unsaved Changes
|
|
117
|
+
</h2>
|
|
118
|
+
</div>
|
|
119
|
+
<button
|
|
120
|
+
className="wn-unsaved-modal-close"
|
|
121
|
+
onClick={onClose}
|
|
122
|
+
disabled={isSaving}
|
|
123
|
+
title="Close (Esc)"
|
|
124
|
+
aria-label="Close modal"
|
|
125
|
+
>
|
|
126
|
+
<X size={16} />
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Body */}
|
|
131
|
+
<div className="wn-unsaved-modal-body">
|
|
132
|
+
<p id="unsaved-modal-description" className="wn-unsaved-modal-text">
|
|
133
|
+
You have unsaved changes. Save them before continuing?
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Footer */}
|
|
138
|
+
<div className="wn-unsaved-modal-footer">
|
|
139
|
+
<button
|
|
140
|
+
ref={discardButtonRef}
|
|
141
|
+
type="button"
|
|
142
|
+
className="wn-unsaved-modal-btn wn-unsaved-modal-btn--secondary"
|
|
143
|
+
onClick={onDiscard}
|
|
144
|
+
disabled={isSaving}
|
|
145
|
+
>
|
|
146
|
+
Don't Save
|
|
147
|
+
</button>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
className="wn-unsaved-modal-btn wn-unsaved-modal-btn--primary"
|
|
151
|
+
onClick={onSave}
|
|
152
|
+
disabled={isSaving}
|
|
153
|
+
>
|
|
154
|
+
{isSaving ? "Saving..." : "Save"}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UnsavedChangesModal } from "./UnsavedChangesModal";
|