@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.28.5",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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"