@leavepulse/ui 0.28.5 → 0.29.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/composables/useFormDraft.d.ts +67 -0
- package/dist/composables/useFormDraft.js +124 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/composables/useFormDraft.ts +259 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { MaybeRefOrGetter, Ref } from 'vue';
|
|
2
|
+
export interface UseFormDraftOptions<T> {
|
|
3
|
+
/**
|
|
4
|
+
* Where this draft lives. Include whatever identifies the subject — a draft
|
|
5
|
+
* for device A must not surface while editing device B.
|
|
6
|
+
*/
|
|
7
|
+
key: MaybeRefOrGetter<string>;
|
|
8
|
+
/** The form's current value. Watched deeply; every change rewrites the draft. */
|
|
9
|
+
value: MaybeRefOrGetter<T>;
|
|
10
|
+
/**
|
|
11
|
+
* Whether there is anything worth keeping. Return false while the form still
|
|
12
|
+
* matches what was loaded, so an untouched form leaves nothing behind and no
|
|
13
|
+
* later visit is offered its own starting state back as a "draft".
|
|
14
|
+
*/
|
|
15
|
+
dirty?: MaybeRefOrGetter<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* How long a draft stays offerable, in milliseconds. Older ones are dropped
|
|
18
|
+
* on read: a fortnight-old draft of a record that has been edited twice since
|
|
19
|
+
* is not a rescue, it is a way to overwrite current facts with stale ones.
|
|
20
|
+
* Defaults to a week. `0` keeps them indefinitely.
|
|
21
|
+
*/
|
|
22
|
+
maxAgeMs?: number;
|
|
23
|
+
/** Skip the write without tearing the composable out of the component. */
|
|
24
|
+
disabled?: MaybeRefOrGetter<boolean>;
|
|
25
|
+
}
|
|
26
|
+
export interface UseFormDraft<T> {
|
|
27
|
+
/** A draft was found for this key, and has not been restored or discarded. */
|
|
28
|
+
hasDraft: Ref<boolean>;
|
|
29
|
+
/** When that draft was written, ISO-8601, or `null` when there is none. */
|
|
30
|
+
savedAt: Ref<string | null>;
|
|
31
|
+
/**
|
|
32
|
+
* The stored value, or `null`. Reading does not apply it — the caller decides
|
|
33
|
+
* what to do with each field, because only it knows the form's shape.
|
|
34
|
+
*/
|
|
35
|
+
read: () => T | null;
|
|
36
|
+
/** Forget the draft. Call after applying it, and after a successful save. */
|
|
37
|
+
discard: () => void;
|
|
38
|
+
/** Write now, ignoring `dirty`. For "save and come back to this later". */
|
|
39
|
+
save: () => void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Keeps an unsaved form in browser storage, so losing the page does not lose
|
|
43
|
+
* what was typed into it.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const draft = useFormDraft({
|
|
47
|
+
* key: () => `lp.draft.device-power:${deviceId.value}`,
|
|
48
|
+
* value: () => interfaces.value,
|
|
49
|
+
* dirty: () => hasUnsavedChanges.value,
|
|
50
|
+
* })
|
|
51
|
+
* ```
|
|
52
|
+
* ```vue
|
|
53
|
+
* <LpAlert v-if="draft.hasDraft.value" variant="info">
|
|
54
|
+
* {{ t("draft.found", { when: draft.savedAt.value }) }}
|
|
55
|
+
* <LpButton @click="applyDraft">Restore</LpButton>
|
|
56
|
+
* <LpButton variant="ghost" @click="draft.discard()">Discard</LpButton>
|
|
57
|
+
* </LpAlert>
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* `hasDraft` answers for the key as it is now, so a form that switches subject
|
|
61
|
+
* re-reads rather than carrying the previous subject's answer.
|
|
62
|
+
*
|
|
63
|
+
* Call `discard()` once the record is saved. A draft that outlives the save it
|
|
64
|
+
* describes is offered back on the next visit as though it were newer than the
|
|
65
|
+
* record, which is how a form helpfully undoes somebody's work.
|
|
66
|
+
*/
|
|
67
|
+
export declare function useFormDraft<T>(options: UseFormDraftOptions<T>): UseFormDraft<T>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ref, watch, toValue, onScopeDispose } from "vue";
|
|
2
|
+
const ENVELOPE_VERSION = 1;
|
|
3
|
+
function storage() {
|
|
4
|
+
try {
|
|
5
|
+
if (typeof window === "undefined") return null;
|
|
6
|
+
return window.localStorage;
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
12
|
+
function useFormDraft(options) {
|
|
13
|
+
const hasDraft = ref(false);
|
|
14
|
+
const savedAt = ref(null);
|
|
15
|
+
const maxAge = options.maxAgeMs ?? WEEK_MS;
|
|
16
|
+
function currentKey() {
|
|
17
|
+
return String(toValue(options.key) || "").trim();
|
|
18
|
+
}
|
|
19
|
+
function read() {
|
|
20
|
+
const store = storage();
|
|
21
|
+
const key = currentKey();
|
|
22
|
+
if (!store || !key) return null;
|
|
23
|
+
let raw = null;
|
|
24
|
+
try {
|
|
25
|
+
raw = store.getItem(key);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
if (!parsed || parsed.version !== ENVELOPE_VERSION) {
|
|
33
|
+
discard();
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (maxAge > 0) {
|
|
37
|
+
const age = Date.now() - Date.parse(parsed.savedAt);
|
|
38
|
+
if (age > maxAge) {
|
|
39
|
+
discard();
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return parsed.value;
|
|
44
|
+
} catch {
|
|
45
|
+
discard();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function write(value) {
|
|
50
|
+
const store = storage();
|
|
51
|
+
const key = currentKey();
|
|
52
|
+
if (!store || !key) return;
|
|
53
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
54
|
+
try {
|
|
55
|
+
store.setItem(
|
|
56
|
+
key,
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
version: ENVELOPE_VERSION,
|
|
59
|
+
savedAt: stamp,
|
|
60
|
+
value
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
hasDraft.value = true;
|
|
67
|
+
savedAt.value = stamp;
|
|
68
|
+
}
|
|
69
|
+
function discard() {
|
|
70
|
+
const store = storage();
|
|
71
|
+
const key = currentKey();
|
|
72
|
+
hasDraft.value = false;
|
|
73
|
+
savedAt.value = null;
|
|
74
|
+
if (!store || !key) return;
|
|
75
|
+
try {
|
|
76
|
+
store.removeItem(key);
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function save() {
|
|
81
|
+
write(toValue(options.value));
|
|
82
|
+
}
|
|
83
|
+
watch(
|
|
84
|
+
() => currentKey(),
|
|
85
|
+
() => {
|
|
86
|
+
hasDraft.value = false;
|
|
87
|
+
savedAt.value = null;
|
|
88
|
+
const store = storage();
|
|
89
|
+
const key = currentKey();
|
|
90
|
+
if (!store || !key) return;
|
|
91
|
+
let raw = null;
|
|
92
|
+
try {
|
|
93
|
+
raw = store.getItem(key);
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!raw) return;
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(raw);
|
|
100
|
+
if (parsed?.version !== ENVELOPE_VERSION) return;
|
|
101
|
+
hasDraft.value = true;
|
|
102
|
+
savedAt.value = parsed.savedAt;
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ immediate: true }
|
|
107
|
+
);
|
|
108
|
+
watch(
|
|
109
|
+
() => [toValue(options.value), toValue(options.dirty), toValue(options.disabled)],
|
|
110
|
+
([value, dirty, disabled]) => {
|
|
111
|
+
if (disabled) return;
|
|
112
|
+
if (options.dirty !== void 0 && !dirty) return;
|
|
113
|
+
write(value);
|
|
114
|
+
},
|
|
115
|
+
{ deep: true }
|
|
116
|
+
);
|
|
117
|
+
onScopeDispose(() => {
|
|
118
|
+
hasDraft.value = false;
|
|
119
|
+
});
|
|
120
|
+
return { hasDraft, savedAt, read, discard, save };
|
|
121
|
+
}
|
|
122
|
+
export {
|
|
123
|
+
useFormDraft
|
|
124
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -133,6 +133,8 @@ export { useReveal } from './composables/useReveal';
|
|
|
133
133
|
export type { UseReveal, UseRevealOptions } from './composables/useReveal';
|
|
134
134
|
export { useRovingFocus } from './composables/useRovingFocus';
|
|
135
135
|
export type { RovingItem, UseRovingFocusOptions } from './composables/useRovingFocus';
|
|
136
|
+
export { useFormDraft } from './composables/useFormDraft';
|
|
137
|
+
export type { UseFormDraft, UseFormDraftOptions } from './composables/useFormDraft';
|
|
136
138
|
export { useSettled } from './composables/useSettled';
|
|
137
139
|
export type { UseSettledOptions } from './composables/useSettled';
|
|
138
140
|
export { useShift } from './composables/useShift';
|
package/dist/index.js
CHANGED
|
@@ -85,6 +85,7 @@ import { useInputFilter } from "./composables/useInputFilter.js";
|
|
|
85
85
|
import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
|
|
86
86
|
import { useReveal } from "./composables/useReveal.js";
|
|
87
87
|
import { useRovingFocus } from "./composables/useRovingFocus.js";
|
|
88
|
+
import { useFormDraft } from "./composables/useFormDraft.js";
|
|
88
89
|
import { useSettled } from "./composables/useSettled.js";
|
|
89
90
|
import { useShift } from "./composables/useShift.js";
|
|
90
91
|
import { useTilt } from "./composables/useTilt.js";
|
|
@@ -265,6 +266,7 @@ export {
|
|
|
265
266
|
m as themeToCssVars,
|
|
266
267
|
tileUrl,
|
|
267
268
|
unproject,
|
|
269
|
+
useFormDraft,
|
|
268
270
|
useHotkeys,
|
|
269
271
|
useInputFilter,
|
|
270
272
|
useLayout,
|
package/package.json
CHANGED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
onScopeDispose,
|
|
3
|
+
ref,
|
|
4
|
+
toValue,
|
|
5
|
+
watch,
|
|
6
|
+
type MaybeRefOrGetter,
|
|
7
|
+
type Ref,
|
|
8
|
+
} from "vue"
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* "The form I had filled in is still here."
|
|
12
|
+
*
|
|
13
|
+
* Long forms get abandoned mid-way by things that have nothing to do with the
|
|
14
|
+
* person filling them in: a session that ages out, a reload, a tab closed by
|
|
15
|
+
* accident, a laptop that sleeps. What was typed is held in component state,
|
|
16
|
+
* so all of those end the same way — an empty form and a person who has to
|
|
17
|
+
* remember what they had already read off the thing in their hand.
|
|
18
|
+
*
|
|
19
|
+
* The panel's own inventory forms are the case that prompted this: an operator
|
|
20
|
+
* standing at a shelf, copying figures off a label, is exactly the person who
|
|
21
|
+
* cannot retype them from memory.
|
|
22
|
+
*
|
|
23
|
+
* Consumers were writing this by hand, field by field: one object literal to
|
|
24
|
+
* save thirty values and thirty typed branches to read them back, which has to
|
|
25
|
+
* be kept in step with the form forever and silently stops covering whatever
|
|
26
|
+
* field was added last. This owns the mechanism instead — what to keep is the
|
|
27
|
+
* caller's business, keeping it is not.
|
|
28
|
+
*
|
|
29
|
+
* What this deliberately does NOT do: decide that a draft is worth restoring.
|
|
30
|
+
* It reports one and hands back the value; showing it, and asking, is the
|
|
31
|
+
* caller's, because only the caller knows whether the record moved underneath
|
|
32
|
+
* it in the meantime.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Bumped when the envelope changes shape. A draft written by an older kit is
|
|
36
|
+
* dropped rather than half-read: a partially-applied draft is worse than none,
|
|
37
|
+
* because it looks like something a person typed. */
|
|
38
|
+
const ENVELOPE_VERSION = 1
|
|
39
|
+
|
|
40
|
+
interface DraftEnvelope<T> {
|
|
41
|
+
version: number
|
|
42
|
+
savedAt: string
|
|
43
|
+
value: T
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseFormDraftOptions<T> {
|
|
47
|
+
/**
|
|
48
|
+
* Where this draft lives. Include whatever identifies the subject — a draft
|
|
49
|
+
* for device A must not surface while editing device B.
|
|
50
|
+
*/
|
|
51
|
+
key: MaybeRefOrGetter<string>
|
|
52
|
+
/** The form's current value. Watched deeply; every change rewrites the draft. */
|
|
53
|
+
value: MaybeRefOrGetter<T>
|
|
54
|
+
/**
|
|
55
|
+
* Whether there is anything worth keeping. Return false while the form still
|
|
56
|
+
* matches what was loaded, so an untouched form leaves nothing behind and no
|
|
57
|
+
* later visit is offered its own starting state back as a "draft".
|
|
58
|
+
*/
|
|
59
|
+
dirty?: MaybeRefOrGetter<boolean>
|
|
60
|
+
/**
|
|
61
|
+
* How long a draft stays offerable, in milliseconds. Older ones are dropped
|
|
62
|
+
* on read: a fortnight-old draft of a record that has been edited twice since
|
|
63
|
+
* is not a rescue, it is a way to overwrite current facts with stale ones.
|
|
64
|
+
* Defaults to a week. `0` keeps them indefinitely.
|
|
65
|
+
*/
|
|
66
|
+
maxAgeMs?: number
|
|
67
|
+
/** Skip the write without tearing the composable out of the component. */
|
|
68
|
+
disabled?: MaybeRefOrGetter<boolean>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface UseFormDraft<T> {
|
|
72
|
+
/** A draft was found for this key, and has not been restored or discarded. */
|
|
73
|
+
hasDraft: Ref<boolean>
|
|
74
|
+
/** When that draft was written, ISO-8601, or `null` when there is none. */
|
|
75
|
+
savedAt: Ref<string | null>
|
|
76
|
+
/**
|
|
77
|
+
* The stored value, or `null`. Reading does not apply it — the caller decides
|
|
78
|
+
* what to do with each field, because only it knows the form's shape.
|
|
79
|
+
*/
|
|
80
|
+
read: () => T | null
|
|
81
|
+
/** Forget the draft. Call after applying it, and after a successful save. */
|
|
82
|
+
discard: () => void
|
|
83
|
+
/** Write now, ignoring `dirty`. For "save and come back to this later". */
|
|
84
|
+
save: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function storage(): Storage | null {
|
|
88
|
+
// Private windows, storage disabled by policy, SSR. A form that cannot keep
|
|
89
|
+
// a draft still has to work, so every access is allowed to fail.
|
|
90
|
+
try {
|
|
91
|
+
if (typeof window === "undefined") return null
|
|
92
|
+
return window.localStorage
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Keeps an unsaved form in browser storage, so losing the page does not lose
|
|
102
|
+
* what was typed into it.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const draft = useFormDraft({
|
|
106
|
+
* key: () => `lp.draft.device-power:${deviceId.value}`,
|
|
107
|
+
* value: () => interfaces.value,
|
|
108
|
+
* dirty: () => hasUnsavedChanges.value,
|
|
109
|
+
* })
|
|
110
|
+
* ```
|
|
111
|
+
* ```vue
|
|
112
|
+
* <LpAlert v-if="draft.hasDraft.value" variant="info">
|
|
113
|
+
* {{ t("draft.found", { when: draft.savedAt.value }) }}
|
|
114
|
+
* <LpButton @click="applyDraft">Restore</LpButton>
|
|
115
|
+
* <LpButton variant="ghost" @click="draft.discard()">Discard</LpButton>
|
|
116
|
+
* </LpAlert>
|
|
117
|
+
* ```
|
|
118
|
+
*
|
|
119
|
+
* `hasDraft` answers for the key as it is now, so a form that switches subject
|
|
120
|
+
* re-reads rather than carrying the previous subject's answer.
|
|
121
|
+
*
|
|
122
|
+
* Call `discard()` once the record is saved. A draft that outlives the save it
|
|
123
|
+
* describes is offered back on the next visit as though it were newer than the
|
|
124
|
+
* record, which is how a form helpfully undoes somebody's work.
|
|
125
|
+
*/
|
|
126
|
+
export function useFormDraft<T>(options: UseFormDraftOptions<T>): UseFormDraft<T> {
|
|
127
|
+
const hasDraft = ref(false)
|
|
128
|
+
const savedAt = ref<string | null>(null)
|
|
129
|
+
const maxAge = options.maxAgeMs ?? WEEK_MS
|
|
130
|
+
|
|
131
|
+
function currentKey(): string {
|
|
132
|
+
return String(toValue(options.key) || "").trim()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function read(): T | null {
|
|
136
|
+
const store = storage()
|
|
137
|
+
const key = currentKey()
|
|
138
|
+
if (!store || !key) return null
|
|
139
|
+
let raw: string | null = null
|
|
140
|
+
try {
|
|
141
|
+
raw = store.getItem(key)
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
if (!raw) return null
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(raw) as DraftEnvelope<T>
|
|
148
|
+
if (!parsed || parsed.version !== ENVELOPE_VERSION) {
|
|
149
|
+
discard()
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
if (maxAge > 0) {
|
|
153
|
+
const age = Date.now() - Date.parse(parsed.savedAt)
|
|
154
|
+
// NaN when the stamp is unreadable, and NaN > maxAge is false — an
|
|
155
|
+
// unreadable stamp is treated as "no idea how old", which errs towards
|
|
156
|
+
// offering the draft rather than throwing away somebody's typing.
|
|
157
|
+
if (age > maxAge) {
|
|
158
|
+
discard()
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return parsed.value
|
|
163
|
+
} catch {
|
|
164
|
+
// Corrupt or hand-edited. Nothing to rescue, and leaving it means asking
|
|
165
|
+
// about it on every visit forever.
|
|
166
|
+
discard()
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function write(value: T) {
|
|
172
|
+
const store = storage()
|
|
173
|
+
const key = currentKey()
|
|
174
|
+
if (!store || !key) return
|
|
175
|
+
const stamp = new Date().toISOString()
|
|
176
|
+
try {
|
|
177
|
+
store.setItem(
|
|
178
|
+
key,
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
version: ENVELOPE_VERSION,
|
|
181
|
+
savedAt: stamp,
|
|
182
|
+
value,
|
|
183
|
+
} satisfies DraftEnvelope<T>),
|
|
184
|
+
)
|
|
185
|
+
} catch {
|
|
186
|
+
// Quota, or storage turned off between the read and now. The form is
|
|
187
|
+
// unharmed; it simply has no safety net.
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
hasDraft.value = true
|
|
191
|
+
savedAt.value = stamp
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function discard() {
|
|
195
|
+
const store = storage()
|
|
196
|
+
const key = currentKey()
|
|
197
|
+
hasDraft.value = false
|
|
198
|
+
savedAt.value = null
|
|
199
|
+
if (!store || !key) return
|
|
200
|
+
try {
|
|
201
|
+
store.removeItem(key)
|
|
202
|
+
} catch {
|
|
203
|
+
// Nothing to do about it, and nothing depends on it.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function save() {
|
|
208
|
+
write(toValue(options.value))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Look for an existing draft, and look again when the subject changes.
|
|
212
|
+
watch(
|
|
213
|
+
() => currentKey(),
|
|
214
|
+
() => {
|
|
215
|
+
hasDraft.value = false
|
|
216
|
+
savedAt.value = null
|
|
217
|
+
const store = storage()
|
|
218
|
+
const key = currentKey()
|
|
219
|
+
if (!store || !key) return
|
|
220
|
+
let raw: string | null = null
|
|
221
|
+
try {
|
|
222
|
+
raw = store.getItem(key)
|
|
223
|
+
} catch {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (!raw) return
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(raw) as DraftEnvelope<T>
|
|
229
|
+
if (parsed?.version !== ENVELOPE_VERSION) return
|
|
230
|
+
hasDraft.value = true
|
|
231
|
+
savedAt.value = parsed.savedAt
|
|
232
|
+
} catch {
|
|
233
|
+
// Left for `read` to clear: reporting it here would mean writing to
|
|
234
|
+
// storage from what is meant to be a look.
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
{ immediate: true },
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
watch(
|
|
241
|
+
() => [toValue(options.value), toValue(options.dirty), toValue(options.disabled)],
|
|
242
|
+
([value, dirty, disabled]) => {
|
|
243
|
+
if (disabled) return
|
|
244
|
+
// `dirty` unset means "every change is worth keeping"; the option exists
|
|
245
|
+
// so a form that knows it is untouched leaves no trace.
|
|
246
|
+
if (options.dirty !== undefined && !dirty) return
|
|
247
|
+
write(value as T)
|
|
248
|
+
},
|
|
249
|
+
{ deep: true },
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
onScopeDispose(() => {
|
|
253
|
+
// The draft outlives the component on purpose — that is the whole point —
|
|
254
|
+
// so there is nothing to clean up but the local flags.
|
|
255
|
+
hasDraft.value = false
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return { hasDraft, savedAt, read, discard, save }
|
|
259
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -213,6 +213,9 @@ export { useReveal } from "./composables/useReveal"
|
|
|
213
213
|
export type { UseReveal, UseRevealOptions } from "./composables/useReveal"
|
|
214
214
|
export { useRovingFocus } from "./composables/useRovingFocus"
|
|
215
215
|
export type { RovingItem, UseRovingFocusOptions } from "./composables/useRovingFocus"
|
|
216
|
+
export { useFormDraft } from "./composables/useFormDraft"
|
|
217
|
+
export type { UseFormDraft, UseFormDraftOptions } from "./composables/useFormDraft"
|
|
218
|
+
|
|
216
219
|
export { useSettled } from "./composables/useSettled"
|
|
217
220
|
export type { UseSettledOptions } from "./composables/useSettled"
|
|
218
221
|
export { useShift } from "./composables/useShift"
|