use-form-draft 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/dist/index.cjs ADDED
@@ -0,0 +1,313 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/useFormDraft.ts
7
+ function resolveStorage(custom) {
8
+ if (custom) return custom;
9
+ if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
10
+ return window.localStorage;
11
+ }
12
+ return null;
13
+ }
14
+ function safeGet(storage, key, ttlDays, version) {
15
+ if (!storage) return null;
16
+ try {
17
+ const raw = storage.getItem(key);
18
+ if (!raw) return null;
19
+ const p = JSON.parse(raw);
20
+ if (p.version !== version) return null;
21
+ const ageMs = Date.now() - new Date(p.savedAt).getTime();
22
+ if (ageMs > ttlDays * 864e5) return null;
23
+ return p;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function safeSet(storage, key, state, hadFile, version) {
29
+ if (!storage) return;
30
+ try {
31
+ const p = {
32
+ version,
33
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
34
+ hadFile,
35
+ state
36
+ };
37
+ storage.setItem(key, JSON.stringify(p));
38
+ } catch {
39
+ }
40
+ }
41
+ function safeDel(storage, key) {
42
+ if (!storage) return;
43
+ try {
44
+ storage.removeItem(key);
45
+ } catch {
46
+ }
47
+ }
48
+ function stripExcluded(state, exclude) {
49
+ if (!exclude || exclude.length === 0) return state;
50
+ if (state === null || typeof state !== "object") return state;
51
+ const copy = { ...state };
52
+ for (const key of exclude) {
53
+ delete copy[key];
54
+ }
55
+ return copy;
56
+ }
57
+ function safeStringify(payload) {
58
+ try {
59
+ return JSON.stringify(payload);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ function useFormDraft(key, state, hydrate, options) {
65
+ const ttlDays = options?.ttlDays ?? 30;
66
+ const disabled = options?.disabled ?? false;
67
+ const hasFile = options?.hasFile ?? false;
68
+ const skipRestore = options?.skipRestore ?? false;
69
+ const version = options?.version ?? 1;
70
+ const exclude = options?.exclude;
71
+ const debounceMs = options?.debounceMs ?? 400;
72
+ const crossTab = options?.crossTab ?? false;
73
+ const storage = resolveStorage(options?.storage);
74
+ const [restored, setRestored] = react.useState(false);
75
+ const [savedAt, setSavedAt] = react.useState(null);
76
+ const [hadFile, setHadFile] = react.useState(false);
77
+ const hydrateRef = react.useRef(hydrate);
78
+ react.useEffect(() => {
79
+ hydrateRef.current = hydrate;
80
+ });
81
+ const storageRef = react.useRef(storage);
82
+ storageRef.current = storage;
83
+ const readOptsRef = react.useRef({ ttlDays, version, exclude });
84
+ readOptsRef.current = { ttlDays, version, exclude };
85
+ const lastWrittenJsonRef = react.useRef(null);
86
+ if (lastWrittenJsonRef.current === null) {
87
+ lastWrittenJsonRef.current = safeStringify(stripExcluded(state, exclude));
88
+ }
89
+ const applyDraft = react.useCallback((draft) => {
90
+ hydrateRef.current(draft.state);
91
+ setRestored(true);
92
+ setSavedAt(new Date(draft.savedAt));
93
+ setHadFile(draft.hadFile);
94
+ lastWrittenJsonRef.current = safeStringify(
95
+ stripExcluded(draft.state, readOptsRef.current.exclude)
96
+ );
97
+ }, []);
98
+ react.useEffect(() => {
99
+ if (skipRestore) return;
100
+ const draft = safeGet(storage, key, ttlDays, version);
101
+ if (!draft) return;
102
+ try {
103
+ applyDraft(draft);
104
+ } catch {
105
+ safeDel(storage, key);
106
+ }
107
+ }, []);
108
+ react.useEffect(() => {
109
+ if (!crossTab || typeof window === "undefined") return;
110
+ const onStorage = (e) => {
111
+ if (e.key !== key) return;
112
+ const { ttlDays: ttl, version: ver } = readOptsRef.current;
113
+ if (e.newValue === null) {
114
+ setRestored(false);
115
+ setSavedAt(null);
116
+ setHadFile(false);
117
+ return;
118
+ }
119
+ const draft = safeGet(storageRef.current, key, ttl, ver);
120
+ if (!draft) return;
121
+ try {
122
+ applyDraft(draft);
123
+ } catch {
124
+ safeDel(storageRef.current, key);
125
+ }
126
+ };
127
+ window.addEventListener("storage", onStorage);
128
+ return () => window.removeEventListener("storage", onStorage);
129
+ }, [crossTab, key]);
130
+ const timerRef = react.useRef(null);
131
+ react.useEffect(() => {
132
+ if (disabled) return;
133
+ const payload = stripExcluded(state, exclude);
134
+ const json = safeStringify(payload);
135
+ if (json === null) return;
136
+ if (json === lastWrittenJsonRef.current) return;
137
+ if (timerRef.current) clearTimeout(timerRef.current);
138
+ timerRef.current = setTimeout(() => {
139
+ safeSet(storage, key, payload, hasFile, version);
140
+ lastWrittenJsonRef.current = json;
141
+ }, debounceMs);
142
+ return () => {
143
+ if (timerRef.current) clearTimeout(timerRef.current);
144
+ };
145
+ }, [state, disabled]);
146
+ const clear = react.useCallback(() => {
147
+ safeDel(storageRef.current, key);
148
+ setRestored(false);
149
+ setSavedAt(null);
150
+ setHadFile(false);
151
+ if (timerRef.current) {
152
+ clearTimeout(timerRef.current);
153
+ timerRef.current = null;
154
+ }
155
+ lastWrittenJsonRef.current = null;
156
+ }, [key]);
157
+ return { restored, savedAt, hadFile, clear };
158
+ }
159
+
160
+ // src/relativeTime.ts
161
+ function relativeTime(date, now, locale) {
162
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
163
+ const diffMs = date.getTime() - now;
164
+ const diffSec = Math.round(diffMs / 1e3);
165
+ const diffMin = Math.round(diffMs / 6e4);
166
+ const diffHr = Math.round(diffMs / 36e5);
167
+ const diffDay = Math.round(diffMs / 864e5);
168
+ if (Math.abs(diffMin) < 1) return rtf.format(diffSec, "second");
169
+ if (Math.abs(diffHr) < 1) return rtf.format(diffMin, "minute");
170
+ if (Math.abs(diffDay) < 1) return rtf.format(diffHr, "hour");
171
+ return rtf.format(diffDay, "day");
172
+ }
173
+ var DEFAULT_STYLE = {
174
+ display: "flex",
175
+ alignItems: "center",
176
+ gap: 6,
177
+ padding: "4px 8px 4px 10px",
178
+ background: "var(--ufd-banner-bg, #fffbeb)",
179
+ borderLeft: "2px solid var(--ufd-banner-border, #f59e0b)",
180
+ borderRadius: "0 4px 4px 0",
181
+ marginBottom: 10,
182
+ fontSize: 12,
183
+ lineHeight: 1.3,
184
+ color: "var(--ufd-banner-text, #374151)"
185
+ };
186
+ var BUTTON_STYLE = {
187
+ background: "none",
188
+ border: "none",
189
+ cursor: "pointer",
190
+ color: "var(--ufd-banner-muted, #9ca3af)",
191
+ fontSize: 11,
192
+ padding: "0 4px",
193
+ whiteSpace: "nowrap"
194
+ };
195
+ var ICON_BUTTON_STYLE = {
196
+ background: "none",
197
+ border: "none",
198
+ cursor: "pointer",
199
+ color: "var(--ufd-banner-muted, #9ca3af)",
200
+ padding: 2,
201
+ display: "inline-flex",
202
+ alignItems: "center",
203
+ fontSize: 14,
204
+ lineHeight: 1
205
+ };
206
+ var MUTED_STYLE = {
207
+ color: "var(--ufd-banner-muted, #9ca3af)"
208
+ };
209
+ function DraftBanner({
210
+ savedAt,
211
+ hadFile = false,
212
+ onDiscard,
213
+ autoHideMs = 1e4,
214
+ escDismiss = false,
215
+ locale = "en",
216
+ closeIcon,
217
+ messages,
218
+ className,
219
+ style
220
+ }) {
221
+ const [visible, setVisible] = react.useState(true);
222
+ const [isClient, setIsClient] = react.useState(false);
223
+ react.useEffect(() => {
224
+ setIsClient(true);
225
+ }, []);
226
+ react.useEffect(() => {
227
+ setVisible(true);
228
+ }, [savedAt]);
229
+ react.useEffect(() => {
230
+ if (!autoHideMs) return;
231
+ const t = setTimeout(() => setVisible(false), autoHideMs);
232
+ return () => clearTimeout(t);
233
+ }, [autoHideMs, savedAt]);
234
+ react.useEffect(() => {
235
+ if (!escDismiss || !visible) return;
236
+ const onKey = (e) => {
237
+ if (e.key === "Escape") setVisible(false);
238
+ };
239
+ document.addEventListener("keydown", onKey);
240
+ return () => document.removeEventListener("keydown", onKey);
241
+ }, [escDismiss, visible]);
242
+ if (!isClient || !savedAt || !visible) return null;
243
+ const restored = messages?.restored ?? "Draft restored";
244
+ const reattach = messages?.reattach ?? "re-attach file";
245
+ const discard = messages?.discard ?? "Discard";
246
+ const dismiss = messages?.dismiss ?? "Dismiss";
247
+ return /* @__PURE__ */ jsxRuntime.jsxs(
248
+ "div",
249
+ {
250
+ role: "status",
251
+ className,
252
+ style: { ...DEFAULT_STYLE, ...style },
253
+ children: [
254
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { flex: 1 }, children: [
255
+ restored,
256
+ " ",
257
+ relativeTime(savedAt, Date.now(), locale),
258
+ hadFile && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: MUTED_STYLE, children: [
259
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: " \xB7 " }),
260
+ reattach
261
+ ] })
262
+ ] }),
263
+ /* @__PURE__ */ jsxRuntime.jsx(
264
+ "button",
265
+ {
266
+ type: "button",
267
+ onClick: () => {
268
+ onDiscard();
269
+ setVisible(false);
270
+ },
271
+ style: BUTTON_STYLE,
272
+ children: discard
273
+ }
274
+ ),
275
+ /* @__PURE__ */ jsxRuntime.jsx(
276
+ "button",
277
+ {
278
+ type: "button",
279
+ "aria-label": dismiss,
280
+ onClick: () => setVisible(false),
281
+ style: ICON_BUTTON_STYLE,
282
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: closeIcon ?? "\xD7" })
283
+ }
284
+ )
285
+ ]
286
+ }
287
+ );
288
+ }
289
+ function useDraftBanner(options) {
290
+ const { savedAt, autoHideMs = 1e4, locale = "en" } = options;
291
+ const [visible, setVisible] = react.useState(Boolean(savedAt));
292
+ const [isClient, setIsClient] = react.useState(false);
293
+ react.useEffect(() => {
294
+ setIsClient(true);
295
+ }, []);
296
+ react.useEffect(() => {
297
+ setVisible(Boolean(savedAt));
298
+ }, [savedAt]);
299
+ react.useEffect(() => {
300
+ if (!visible || !autoHideMs) return;
301
+ const t = setTimeout(() => setVisible(false), autoHideMs);
302
+ return () => clearTimeout(t);
303
+ }, [visible, autoHideMs]);
304
+ const dismiss = react.useCallback(() => setVisible(false), []);
305
+ const label = isClient && savedAt ? relativeTime(savedAt, Date.now(), locale) : null;
306
+ return { visible, dismiss, relativeTime: label };
307
+ }
308
+
309
+ exports.DraftBanner = DraftBanner;
310
+ exports.useDraftBanner = useDraftBanner;
311
+ exports.useFormDraft = useFormDraft;
312
+ //# sourceMappingURL=index.cjs.map
313
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useFormDraft.ts","../src/relativeTime.ts","../src/DraftBanner.tsx","../src/useDraftBanner.ts"],"names":["useState","useRef","useEffect","useCallback","jsxs","jsx"],"mappings":"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","file":"index.cjs","sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\n/**\n * Minimal synchronous storage interface. The Web Storage API\n * (`window.localStorage` / `window.sessionStorage`) satisfies it as-is, and\n * you can supply your own adapter (in-memory, encrypted, namespaced, …) as\n * long as it stays synchronous.\n */\nexport interface DraftStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n removeItem(key: string): void;\n}\n\n/** Resolve the storage backend: an explicit adapter, else localStorage, else null (SSR). */\nfunction resolveStorage(custom?: DraftStorage): DraftStorage | null {\n if (custom) return custom;\n if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {\n return window.localStorage;\n }\n return null;\n}\n\nexport interface DraftPayload<T> {\n version: number;\n savedAt: string;\n hadFile: boolean;\n state: T;\n}\n\nexport interface UseFormDraftOptions<T> {\n /** Disables writes while true. Set to true during submit so the in-flight payload isn't persisted. */\n disabled?: boolean;\n /** Skip the restore-on-mount step. Useful for one-shot dismissals. */\n skipRestore?: boolean;\n /** Drafts older than this are discarded silently on read. Default 30. */\n ttlDays?: number;\n /** Whether the form currently has a file attached. Stored as a flag so the banner can prompt re-attach. */\n hasFile?: boolean;\n /**\n * Schema version. Bump this when your state shape changes incompatibly — old drafts will be discarded\n * instead of hydrating into the new shape and crashing. Default 1.\n */\n version?: number;\n /** Keys to strip from state before persisting (passwords, CVVs, one-time tokens). */\n exclude?: ReadonlyArray<keyof T>;\n /** Debounce window in ms for writes. Default 400. */\n debounceMs?: number;\n /**\n * Where to persist. Defaults to `window.localStorage`. Pass `window.sessionStorage` for\n * tab-scoped drafts, or any object implementing {@link DraftStorage}. Must be synchronous —\n * async stores (IndexedDB) aren't supported by this interface yet.\n */\n storage?: DraftStorage;\n /**\n * Keep this instance in sync with edits made in other tabs of the same origin. When true, a\n * draft saved in another tab is restored into this one via the `storage` event. Default false.\n *\n * Only meaningful with `localStorage` (the default) — the `storage` event does not fire for\n * `sessionStorage` (tab-scoped) or for custom adapters. Syncing is last-write-wins, so a remote\n * save can overwrite what the user is currently editing here; opt in deliberately.\n */\n crossTab?: boolean;\n}\n\nexport interface UseFormDraftReturn {\n /** True if a draft was found and successfully hydrated on mount. */\n restored: boolean;\n /** When the restored draft was last saved, or null if no draft was restored. */\n savedAt: Date | null;\n /** Whether the restored draft had a file attached (file content itself is never persisted). */\n hadFile: boolean;\n /** Remove the persisted draft and reset hook state. Call on successful submit. */\n clear: () => void;\n}\n\nfunction safeGet<T>(\n storage: DraftStorage | null,\n key: string,\n ttlDays: number,\n version: number,\n): DraftPayload<T> | null {\n if (!storage) return null;\n try {\n const raw = storage.getItem(key);\n if (!raw) return null;\n const p = JSON.parse(raw) as DraftPayload<T>;\n if (p.version !== version) return null;\n const ageMs = Date.now() - new Date(p.savedAt).getTime();\n if (ageMs > ttlDays * 86_400_000) return null;\n return p;\n } catch {\n return null;\n }\n}\n\nfunction safeSet<T>(\n storage: DraftStorage | null,\n key: string,\n state: T,\n hadFile: boolean,\n version: number,\n): void {\n if (!storage) return;\n try {\n const p: DraftPayload<T> = {\n version,\n savedAt: new Date().toISOString(),\n hadFile,\n state,\n };\n storage.setItem(key, JSON.stringify(p));\n } catch {\n /* quota exceeded / private browsing / disabled */\n }\n}\n\nfunction safeDel(storage: DraftStorage | null, key: string): void {\n if (!storage) return;\n try {\n storage.removeItem(key);\n } catch {\n /* ignore */\n }\n}\n\ntype Persistable<T, E extends ReadonlyArray<keyof T>> = E extends ReadonlyArray<never>\n ? T\n : Omit<T, E[number]>;\n\nfunction stripExcluded<T, E extends ReadonlyArray<keyof T>>(\n state: T,\n exclude: E | undefined,\n): Persistable<T, E> {\n if (!exclude || exclude.length === 0) return state as unknown as Persistable<T, E>;\n if (state === null || typeof state !== 'object') return state as unknown as Persistable<T, E>;\n const copy = { ...(state as Record<string, unknown>) };\n for (const key of exclude) {\n delete copy[key as string];\n }\n return copy as unknown as Persistable<T, E>;\n}\n\n/**\n * Stringify safely. Returns null if the payload contains a non-serializable\n * value (BigInt, circular reference, throwing toJSON, Symbol). Callers treat\n * null as \"skip this write\" — we'd rather silently no-op than crash the form.\n */\nfunction safeStringify(payload: unknown): string | null {\n try {\n return JSON.stringify(payload);\n } catch {\n return null;\n }\n}\n\n/**\n * Auto-saves form state to a synchronous store (localStorage by default) with a debounced write,\n * and restores it on mount.\n *\n * @param key Storage key. **Must be stable for the component's lifetime in v0.1.** Changing\n * the key while mounted has two failure modes: (1) the new key's existing draft\n * is NOT restored (the restore effect runs once on mount); (2) any pending\n * debounced write for the old key still writes to the old key. If you need a key\n * that depends on a route param or entity id, unmount + remount the component\n * with the new key. Key-change handling is planned for a later release.\n * Two components mounting with the same key concurrently will race; last write\n * wins. For cross-tab coordination, see the `crossTab` option.\n * @param state The form state to persist. Re-runs the write check whenever this changes.\n * Writes only fire when the persisted JSON actually differs from the last write —\n * parent re-renders with unchanged values are no-ops.\n * @param hydrate Called once on mount if a valid draft is found (and again on cross-tab updates\n * when `crossTab` is enabled). Wire it to your form's setter.\n * @param options See {@link UseFormDraftOptions}.\n */\nexport function useFormDraft<T>(\n key: string,\n state: T,\n hydrate: (draft: T) => void,\n options?: UseFormDraftOptions<T>,\n): UseFormDraftReturn {\n const ttlDays = options?.ttlDays ?? 30;\n const disabled = options?.disabled ?? false;\n const hasFile = options?.hasFile ?? false;\n const skipRestore = options?.skipRestore ?? false;\n const version = options?.version ?? 1;\n const exclude = options?.exclude;\n const debounceMs = options?.debounceMs ?? 400;\n const crossTab = options?.crossTab ?? false;\n\n const storage = resolveStorage(options?.storage);\n\n const [restored, setRestored] = useState(false);\n const [savedAt, setSavedAt] = useState<Date | null>(null);\n const [hadFile, setHadFile] = useState(false);\n\n const hydrateRef = useRef(hydrate);\n useEffect(() => {\n hydrateRef.current = hydrate;\n });\n\n // Keep the resolved storage and read-time options reachable from event-listener\n // closures (the cross-tab effect) without re-subscribing on every render.\n const storageRef = useRef(storage);\n storageRef.current = storage;\n const readOptsRef = useRef({ ttlDays, version, exclude });\n readOptsRef.current = { ttlDays, version, exclude };\n\n // Seed once with the initial persistable JSON — so the very first effect run\n // (and StrictMode's double-mount) sees \"no change vs initial\" and writes nothing.\n // Also prevents writes on parent re-renders where `state` is a new reference\n // but its JSON is identical (e.g. RHF's form.watch() snapshot).\n // safeStringify returns null on non-serializable input; we leave the ref null\n // in that case and the write effect will also no-op (also returning null).\n const lastWrittenJsonRef = useRef<string | null>(null);\n if (lastWrittenJsonRef.current === null) {\n lastWrittenJsonRef.current = safeStringify(stripExcluded(state, exclude));\n }\n\n // Apply a freshly-read payload into hook state + the host form. Shared by the\n // mount-restore effect and the cross-tab listener.\n const applyDraft = useCallback((draft: DraftPayload<T>) => {\n hydrateRef.current(draft.state);\n setRestored(true);\n setSavedAt(new Date(draft.savedAt));\n setHadFile(draft.hadFile);\n // Seed lastWritten so the post-hydrate render doesn't re-persist what we just restored.\n lastWrittenJsonRef.current = safeStringify(\n stripExcluded(draft.state, readOptsRef.current.exclude),\n );\n }, []);\n\n // Restore on mount\n useEffect(() => {\n if (skipRestore) return;\n const draft = safeGet<T>(storage, key, ttlDays, version);\n if (!draft) return;\n try {\n applyDraft(draft);\n } catch {\n safeDel(storage, key);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Cross-tab sync: another tab saving (or clearing) this key fires a `storage`\n // event here. We re-read through safeGet so version/ttl validation still applies.\n useEffect(() => {\n if (!crossTab || typeof window === 'undefined') return;\n const onStorage = (e: StorageEvent) => {\n if (e.key !== key) return;\n const { ttlDays: ttl, version: ver } = readOptsRef.current;\n if (e.newValue === null) {\n // Another tab cleared the draft. Don't clobber what the user is typing here;\n // just drop our restored badge so stale \"restored N ago\" UI goes away.\n setRestored(false);\n setSavedAt(null);\n setHadFile(false);\n return;\n }\n const draft = safeGet<T>(storageRef.current, key, ttl, ver);\n if (!draft) return;\n try {\n applyDraft(draft);\n } catch {\n safeDel(storageRef.current, key);\n }\n };\n window.addEventListener('storage', onStorage);\n return () => window.removeEventListener('storage', onStorage);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [crossTab, key]);\n\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Debounced write — only when the persistable JSON actually changes.\n useEffect(() => {\n if (disabled) return;\n const payload = stripExcluded(state, exclude);\n const json = safeStringify(payload);\n // Non-serializable payload (BigInt, circular ref, throwing toJSON): skip silently.\n // We never want a write to crash the form.\n if (json === null) return;\n if (json === lastWrittenJsonRef.current) return;\n\n if (timerRef.current) clearTimeout(timerRef.current);\n timerRef.current = setTimeout(() => {\n safeSet(storage, key, payload, hasFile, version);\n lastWrittenJsonRef.current = json;\n }, debounceMs);\n\n return () => {\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [state, disabled]);\n\n const clear = useCallback(() => {\n safeDel(storageRef.current, key);\n setRestored(false);\n setSavedAt(null);\n setHadFile(false);\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n // Re-seed on next render so a subsequent state reset (e.g. setForm(empty) on submit)\n // doesn't compare against a stale pre-clear payload and re-persist the reset state.\n lastWrittenJsonRef.current = null;\n }, [key]);\n\n return { restored, savedAt, hadFile, clear };\n}\n","/**\n * Format `date` relative to `now` (a `Date.now()` timestamp) using\n * `Intl.RelativeTimeFormat` — e.g. \"3 minutes ago\", \"in 2 hours\".\n *\n * Shared by both `DraftBanner` and the headless `useDraftBanner` so the two\n * surfaces phrase time identically.\n */\nexport function relativeTime(date: Date, now: number, locale: string): string {\n const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });\n const diffMs = date.getTime() - now;\n const diffSec = Math.round(diffMs / 1000);\n const diffMin = Math.round(diffMs / 60_000);\n const diffHr = Math.round(diffMs / 3_600_000);\n const diffDay = Math.round(diffMs / 86_400_000);\n\n if (Math.abs(diffMin) < 1) return rtf.format(diffSec, 'second');\n if (Math.abs(diffHr) < 1) return rtf.format(diffMin, 'minute');\n if (Math.abs(diffDay) < 1) return rtf.format(diffHr, 'hour');\n return rtf.format(diffDay, 'day');\n}\n","import { type CSSProperties, type ReactNode, useEffect, useState } from 'react';\nimport { relativeTime } from './relativeTime';\n\nexport interface DraftBannerProps {\n /** From `useFormDraft().savedAt`. The banner does not render while this is null. */\n savedAt: Date | null;\n /** From `useFormDraft().hadFile`. When true, appends a hint that the file needs re-attaching. */\n hadFile?: boolean;\n /** Called when the user clicks Discard. Wire to `useFormDraft().clear`. */\n onDiscard: () => void;\n /**\n * ms before the banner auto-hides. Default 10000 (per WAI-ARIA live-region guidance —\n * shorter is risky for screen-reader users). Set to 0 to disable auto-hide.\n */\n autoHideMs?: number;\n /** Locale for `Intl.RelativeTimeFormat`. Default 'en'. Pairs with `messages` for full i18n. */\n locale?: string;\n /**\n * Listen for the Escape key on `document` and dismiss the banner. Default false.\n *\n * This is a **global** listener — if your app uses modals/dialogs that handle Escape\n * themselves, leave this off to avoid the banner getting dismissed alongside the modal.\n */\n escDismiss?: boolean;\n /** Override the close icon. Default is a unicode × character. */\n closeIcon?: ReactNode;\n /** Override i18n strings. */\n messages?: {\n /** Prefix before the relative time. Default: \"Draft restored\". */\n restored?: string;\n /** Hint shown when hadFile is true. Default: \"re-attach file\". */\n reattach?: string;\n /** Discard button label. Default: \"Discard\". */\n discard?: string;\n /** Close button aria-label. Default: \"Dismiss\". */\n dismiss?: string;\n };\n /** className applied to the outer container so consumers can override styling. */\n className?: string;\n /** Inline style overrides merged onto the outer container. */\n style?: CSSProperties;\n}\n\nconst DEFAULT_STYLE: CSSProperties = {\n display: 'flex',\n alignItems: 'center',\n gap: 6,\n padding: '4px 8px 4px 10px',\n background: 'var(--ufd-banner-bg, #fffbeb)',\n borderLeft: '2px solid var(--ufd-banner-border, #f59e0b)',\n borderRadius: '0 4px 4px 0',\n marginBottom: 10,\n fontSize: 12,\n lineHeight: 1.3,\n color: 'var(--ufd-banner-text, #374151)',\n};\n\nconst BUTTON_STYLE: CSSProperties = {\n background: 'none',\n border: 'none',\n cursor: 'pointer',\n color: 'var(--ufd-banner-muted, #9ca3af)',\n fontSize: 11,\n padding: '0 4px',\n whiteSpace: 'nowrap',\n};\n\nconst ICON_BUTTON_STYLE: CSSProperties = {\n background: 'none',\n border: 'none',\n cursor: 'pointer',\n color: 'var(--ufd-banner-muted, #9ca3af)',\n padding: 2,\n display: 'inline-flex',\n alignItems: 'center',\n fontSize: 14,\n lineHeight: 1,\n};\n\nconst MUTED_STYLE: CSSProperties = {\n color: 'var(--ufd-banner-muted, #9ca3af)',\n};\n\nexport function DraftBanner({\n savedAt,\n hadFile = false,\n onDiscard,\n autoHideMs = 10_000,\n escDismiss = false,\n locale = 'en',\n closeIcon,\n messages,\n className,\n style,\n}: DraftBannerProps) {\n const [visible, setVisible] = useState(true);\n\n // Gate render behind a client-side flag so SSR output matches the first client render.\n // We can't compute relativeTime() without Date.now(), which would otherwise hydrate-mismatch.\n const [isClient, setIsClient] = useState(false);\n useEffect(() => {\n setIsClient(true);\n }, []);\n\n // Re-show on savedAt transition — parity with useDraftBanner, so a fresh\n // restore after a previous dismiss / auto-hide brings the banner back.\n useEffect(() => {\n setVisible(true);\n }, [savedAt]);\n\n useEffect(() => {\n if (!autoHideMs) return;\n const t = setTimeout(() => setVisible(false), autoHideMs);\n return () => clearTimeout(t);\n }, [autoHideMs, savedAt]);\n\n useEffect(() => {\n if (!escDismiss || !visible) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') setVisible(false);\n };\n document.addEventListener('keydown', onKey);\n return () => document.removeEventListener('keydown', onKey);\n }, [escDismiss, visible]);\n\n if (!isClient || !savedAt || !visible) return null;\n\n const restored = messages?.restored ?? 'Draft restored';\n const reattach = messages?.reattach ?? 're-attach file';\n const discard = messages?.discard ?? 'Discard';\n const dismiss = messages?.dismiss ?? 'Dismiss';\n\n return (\n <div\n role=\"status\"\n className={className}\n style={{ ...DEFAULT_STYLE, ...style }}\n >\n <span style={{ flex: 1 }}>\n {restored} {relativeTime(savedAt, Date.now(), locale)}\n {hadFile && (\n <span style={MUTED_STYLE}>\n <span aria-hidden=\"true\"> · </span>\n {reattach}\n </span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={() => {\n onDiscard();\n setVisible(false);\n }}\n style={BUTTON_STYLE}\n >\n {discard}\n </button>\n <button\n type=\"button\"\n aria-label={dismiss}\n onClick={() => setVisible(false)}\n style={ICON_BUTTON_STYLE}\n >\n <span aria-hidden=\"true\">{closeIcon ?? '×'}</span>\n </button>\n </div>\n );\n}\n","import { useCallback, useEffect, useState } from 'react';\nimport { relativeTime } from './relativeTime';\n\nexport interface UseDraftBannerOptions {\n /** When the restored draft was last saved. Drives the relative-time label and triggers re-show on change. */\n savedAt: Date | null;\n /**\n * ms before `visible` flips to false on its own. Default 10000 (matches\n * {@link DraftBanner} and follows WAI-ARIA live-region guidance — shorter\n * is risky for screen reader users). 0 disables auto-hide.\n */\n autoHideMs?: number;\n /** Locale for `Intl.RelativeTimeFormat`. Default 'en'. */\n locale?: string;\n}\n\nexport interface UseDraftBannerReturn {\n /** True while the banner should render. Becomes false on dismiss or after autoHideMs. */\n visible: boolean;\n /** Imperatively hide the banner. */\n dismiss: () => void;\n /** Human-readable relative time, e.g. \"3 hours ago\" or null if savedAt is null. */\n relativeTime: string | null;\n}\n\n/**\n * Headless banner state. Wire it to your own UI when the bundled {@link DraftBanner} doesn't fit.\n *\n * @example\n * const draft = useFormDraft(...);\n * const banner = useDraftBanner({ savedAt: draft.savedAt });\n * return banner.visible ? (\n * <MyBanner>{banner.relativeTime}<button onClick={banner.dismiss}>×</button></MyBanner>\n * ) : null;\n */\nexport function useDraftBanner(options: UseDraftBannerOptions): UseDraftBannerReturn {\n const { savedAt, autoHideMs = 10_000, locale = 'en' } = options;\n const [visible, setVisible] = useState(Boolean(savedAt));\n\n // Gate Date.now()-derived output behind a post-mount flag to avoid SSR/client\n // hydration mismatch. On the server `relativeTime` is null; the first client\n // render also returns null (matching the server); the second render (after\n // mount) computes the real value.\n const [isClient, setIsClient] = useState(false);\n useEffect(() => {\n setIsClient(true);\n }, []);\n\n useEffect(() => {\n setVisible(Boolean(savedAt));\n }, [savedAt]);\n\n useEffect(() => {\n if (!visible || !autoHideMs) return;\n const t = setTimeout(() => setVisible(false), autoHideMs);\n return () => clearTimeout(t);\n }, [visible, autoHideMs]);\n\n const dismiss = useCallback(() => setVisible(false), []);\n\n const label = isClient && savedAt ? relativeTime(savedAt, Date.now(), locale) : null;\n\n return { visible, dismiss, relativeTime: label };\n}\n"]}
@@ -0,0 +1,78 @@
1
+ export { D as DraftPayload, a as DraftStorage, U as UseFormDraftOptions, b as UseFormDraftReturn, u as useFormDraft } from './useFormDraft-BFyNp_2I.cjs';
2
+ import * as react from 'react';
3
+ import { ReactNode, CSSProperties } from 'react';
4
+
5
+ interface DraftBannerProps {
6
+ /** From `useFormDraft().savedAt`. The banner does not render while this is null. */
7
+ savedAt: Date | null;
8
+ /** From `useFormDraft().hadFile`. When true, appends a hint that the file needs re-attaching. */
9
+ hadFile?: boolean;
10
+ /** Called when the user clicks Discard. Wire to `useFormDraft().clear`. */
11
+ onDiscard: () => void;
12
+ /**
13
+ * ms before the banner auto-hides. Default 10000 (per WAI-ARIA live-region guidance —
14
+ * shorter is risky for screen-reader users). Set to 0 to disable auto-hide.
15
+ */
16
+ autoHideMs?: number;
17
+ /** Locale for `Intl.RelativeTimeFormat`. Default 'en'. Pairs with `messages` for full i18n. */
18
+ locale?: string;
19
+ /**
20
+ * Listen for the Escape key on `document` and dismiss the banner. Default false.
21
+ *
22
+ * This is a **global** listener — if your app uses modals/dialogs that handle Escape
23
+ * themselves, leave this off to avoid the banner getting dismissed alongside the modal.
24
+ */
25
+ escDismiss?: boolean;
26
+ /** Override the close icon. Default is a unicode × character. */
27
+ closeIcon?: ReactNode;
28
+ /** Override i18n strings. */
29
+ messages?: {
30
+ /** Prefix before the relative time. Default: "Draft restored". */
31
+ restored?: string;
32
+ /** Hint shown when hadFile is true. Default: "re-attach file". */
33
+ reattach?: string;
34
+ /** Discard button label. Default: "Discard". */
35
+ discard?: string;
36
+ /** Close button aria-label. Default: "Dismiss". */
37
+ dismiss?: string;
38
+ };
39
+ /** className applied to the outer container so consumers can override styling. */
40
+ className?: string;
41
+ /** Inline style overrides merged onto the outer container. */
42
+ style?: CSSProperties;
43
+ }
44
+ declare function DraftBanner({ savedAt, hadFile, onDiscard, autoHideMs, escDismiss, locale, closeIcon, messages, className, style, }: DraftBannerProps): react.JSX.Element | null;
45
+
46
+ interface UseDraftBannerOptions {
47
+ /** When the restored draft was last saved. Drives the relative-time label and triggers re-show on change. */
48
+ savedAt: Date | null;
49
+ /**
50
+ * ms before `visible` flips to false on its own. Default 10000 (matches
51
+ * {@link DraftBanner} and follows WAI-ARIA live-region guidance — shorter
52
+ * is risky for screen reader users). 0 disables auto-hide.
53
+ */
54
+ autoHideMs?: number;
55
+ /** Locale for `Intl.RelativeTimeFormat`. Default 'en'. */
56
+ locale?: string;
57
+ }
58
+ interface UseDraftBannerReturn {
59
+ /** True while the banner should render. Becomes false on dismiss or after autoHideMs. */
60
+ visible: boolean;
61
+ /** Imperatively hide the banner. */
62
+ dismiss: () => void;
63
+ /** Human-readable relative time, e.g. "3 hours ago" or null if savedAt is null. */
64
+ relativeTime: string | null;
65
+ }
66
+ /**
67
+ * Headless banner state. Wire it to your own UI when the bundled {@link DraftBanner} doesn't fit.
68
+ *
69
+ * @example
70
+ * const draft = useFormDraft(...);
71
+ * const banner = useDraftBanner({ savedAt: draft.savedAt });
72
+ * return banner.visible ? (
73
+ * <MyBanner>{banner.relativeTime}<button onClick={banner.dismiss}>×</button></MyBanner>
74
+ * ) : null;
75
+ */
76
+ declare function useDraftBanner(options: UseDraftBannerOptions): UseDraftBannerReturn;
77
+
78
+ export { DraftBanner, type DraftBannerProps, type UseDraftBannerOptions, type UseDraftBannerReturn, useDraftBanner };
@@ -0,0 +1,78 @@
1
+ export { D as DraftPayload, a as DraftStorage, U as UseFormDraftOptions, b as UseFormDraftReturn, u as useFormDraft } from './useFormDraft-BFyNp_2I.js';
2
+ import * as react from 'react';
3
+ import { ReactNode, CSSProperties } from 'react';
4
+
5
+ interface DraftBannerProps {
6
+ /** From `useFormDraft().savedAt`. The banner does not render while this is null. */
7
+ savedAt: Date | null;
8
+ /** From `useFormDraft().hadFile`. When true, appends a hint that the file needs re-attaching. */
9
+ hadFile?: boolean;
10
+ /** Called when the user clicks Discard. Wire to `useFormDraft().clear`. */
11
+ onDiscard: () => void;
12
+ /**
13
+ * ms before the banner auto-hides. Default 10000 (per WAI-ARIA live-region guidance —
14
+ * shorter is risky for screen-reader users). Set to 0 to disable auto-hide.
15
+ */
16
+ autoHideMs?: number;
17
+ /** Locale for `Intl.RelativeTimeFormat`. Default 'en'. Pairs with `messages` for full i18n. */
18
+ locale?: string;
19
+ /**
20
+ * Listen for the Escape key on `document` and dismiss the banner. Default false.
21
+ *
22
+ * This is a **global** listener — if your app uses modals/dialogs that handle Escape
23
+ * themselves, leave this off to avoid the banner getting dismissed alongside the modal.
24
+ */
25
+ escDismiss?: boolean;
26
+ /** Override the close icon. Default is a unicode × character. */
27
+ closeIcon?: ReactNode;
28
+ /** Override i18n strings. */
29
+ messages?: {
30
+ /** Prefix before the relative time. Default: "Draft restored". */
31
+ restored?: string;
32
+ /** Hint shown when hadFile is true. Default: "re-attach file". */
33
+ reattach?: string;
34
+ /** Discard button label. Default: "Discard". */
35
+ discard?: string;
36
+ /** Close button aria-label. Default: "Dismiss". */
37
+ dismiss?: string;
38
+ };
39
+ /** className applied to the outer container so consumers can override styling. */
40
+ className?: string;
41
+ /** Inline style overrides merged onto the outer container. */
42
+ style?: CSSProperties;
43
+ }
44
+ declare function DraftBanner({ savedAt, hadFile, onDiscard, autoHideMs, escDismiss, locale, closeIcon, messages, className, style, }: DraftBannerProps): react.JSX.Element | null;
45
+
46
+ interface UseDraftBannerOptions {
47
+ /** When the restored draft was last saved. Drives the relative-time label and triggers re-show on change. */
48
+ savedAt: Date | null;
49
+ /**
50
+ * ms before `visible` flips to false on its own. Default 10000 (matches
51
+ * {@link DraftBanner} and follows WAI-ARIA live-region guidance — shorter
52
+ * is risky for screen reader users). 0 disables auto-hide.
53
+ */
54
+ autoHideMs?: number;
55
+ /** Locale for `Intl.RelativeTimeFormat`. Default 'en'. */
56
+ locale?: string;
57
+ }
58
+ interface UseDraftBannerReturn {
59
+ /** True while the banner should render. Becomes false on dismiss or after autoHideMs. */
60
+ visible: boolean;
61
+ /** Imperatively hide the banner. */
62
+ dismiss: () => void;
63
+ /** Human-readable relative time, e.g. "3 hours ago" or null if savedAt is null. */
64
+ relativeTime: string | null;
65
+ }
66
+ /**
67
+ * Headless banner state. Wire it to your own UI when the bundled {@link DraftBanner} doesn't fit.
68
+ *
69
+ * @example
70
+ * const draft = useFormDraft(...);
71
+ * const banner = useDraftBanner({ savedAt: draft.savedAt });
72
+ * return banner.visible ? (
73
+ * <MyBanner>{banner.relativeTime}<button onClick={banner.dismiss}>×</button></MyBanner>
74
+ * ) : null;
75
+ */
76
+ declare function useDraftBanner(options: UseDraftBannerOptions): UseDraftBannerReturn;
77
+
78
+ export { DraftBanner, type DraftBannerProps, type UseDraftBannerOptions, type UseDraftBannerReturn, useDraftBanner };