Skip to content

Commit e3e2d4e

Browse files
committed
Fixed timer state in combination with navigation events.
1 parent 6d8e5f9 commit e3e2d4e

File tree

3 files changed

+230
-199
lines changed

3 files changed

+230
-199
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ Headlines: Added, Changed, Deprecated, Removed, Fixed, Security
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [1.4.0] - 2023-07-20
99

1010
### Fixed
1111

1212
- When multiple forms exists with the same id, the warning is now displayed on `superForm` instantiation, not just when new form data is received.
1313
- Fixed client-side validation for `Date` schema fields. ([#232](https://github.com/ciscoheat/sveltekit-superforms/issues/232))
1414
- `numberProxy` and `intProxy` now works with the `empty` option. ([#232](https://github.com/ciscoheat/sveltekit-superforms/issues/232))
15+
- Fixed timer state in combination with navigation events.
1516

1617
### Added
1718

src/lib/client/form.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type { AnyZodObject } from 'zod';
2+
import { isElementInViewport, scrollToAndCenter } from './elements.js';
3+
import type { FormOptions } from './index.js';
4+
import { onDestroy, tick } from 'svelte';
5+
import type { Writable } from 'svelte/store';
6+
import { afterNavigate } from '$app/navigation';
7+
8+
enum FetchStatus {
9+
Idle = 0,
10+
Submitting = 1,
11+
Delayed = 2,
12+
Timeout = 3
13+
}
14+
15+
const activeTimers = new Set<() => void>();
16+
let _initialized = false;
17+
18+
/**
19+
* @DCI-context
20+
*/
21+
export function Form<T extends AnyZodObject, M>(
22+
formEl: HTMLFormElement,
23+
timers: {
24+
submitting: Writable<boolean>;
25+
delayed: Writable<boolean>;
26+
timeout: Writable<boolean>;
27+
},
28+
options: FormOptions<T, M>
29+
) {
30+
let state: FetchStatus = FetchStatus.Idle;
31+
let delayedTimeout: number, timeoutTimeout: number;
32+
33+
//#region Timers
34+
35+
const Timers = activeTimers;
36+
37+
// https://www.nngroup.com/articles/response-times-3-important-limits/
38+
function Timers_start() {
39+
Timers_clear();
40+
41+
Timers_setState(
42+
state != FetchStatus.Delayed
43+
? FetchStatus.Submitting
44+
: FetchStatus.Delayed
45+
);
46+
47+
delayedTimeout = window.setTimeout(() => {
48+
if (delayedTimeout && state == FetchStatus.Submitting)
49+
Timers_setState(FetchStatus.Delayed);
50+
}, options.delayMs);
51+
52+
timeoutTimeout = window.setTimeout(() => {
53+
if (timeoutTimeout && state == FetchStatus.Delayed)
54+
Timers_setState(FetchStatus.Timeout);
55+
}, options.timeoutMs);
56+
57+
Timers.add(Timers_clear);
58+
}
59+
60+
/**
61+
* Clear timers and set state to Idle.
62+
*/
63+
function Timers_clear() {
64+
clearTimeout(delayedTimeout);
65+
clearTimeout(timeoutTimeout);
66+
delayedTimeout = timeoutTimeout = 0;
67+
Timers.delete(Timers_clear);
68+
Timers_setState(FetchStatus.Idle);
69+
}
70+
71+
function Timers_clearAll() {
72+
Timers.forEach((t) => t());
73+
Timers.clear();
74+
}
75+
76+
function Timers_setState(s: FetchStatus) {
77+
state = s;
78+
timers.submitting.set(state >= FetchStatus.Submitting);
79+
timers.delayed.set(state >= FetchStatus.Delayed);
80+
timers.timeout.set(state >= FetchStatus.Timeout);
81+
}
82+
83+
//#endregion
84+
85+
//#region ErrorTextEvents
86+
87+
const ErrorTextEvents = formEl;
88+
89+
function ErrorTextEvents__selectText(e: Event) {
90+
const target = e.target as HTMLInputElement;
91+
if (options.selectErrorText) target.select();
92+
}
93+
94+
function ErrorTextEvents_addErrorTextListeners() {
95+
if (!options.selectErrorText) return;
96+
ErrorTextEvents.querySelectorAll('input').forEach((el) => {
97+
el.addEventListener('invalid', ErrorTextEvents__selectText);
98+
});
99+
}
100+
101+
function ErrorTextEvents_removeErrorTextListeners() {
102+
if (!options.selectErrorText) return;
103+
ErrorTextEvents.querySelectorAll('input').forEach((el) =>
104+
el.removeEventListener('invalid', ErrorTextEvents__selectText)
105+
);
106+
}
107+
108+
//#endregion
109+
110+
//#region Form
111+
112+
const Form: {
113+
querySelectorAll: (selector: string) => NodeListOf<HTMLElement>;
114+
querySelector: (selector: string) => HTMLElement;
115+
dataset: DOMStringMap;
116+
} = formEl;
117+
118+
function Form_shouldAutoFocus(userAgent: string) {
119+
if (typeof options.autoFocusOnError === 'boolean')
120+
return options.autoFocusOnError;
121+
else return !/iPhone|iPad|iPod|Android/i.test(userAgent);
122+
}
123+
124+
const Form_scrollToFirstError = async () => {
125+
if (options.scrollToError == 'off') return;
126+
127+
const selector = options.errorSelector;
128+
if (!selector) return;
129+
130+
// Wait for form to update with errors
131+
await tick();
132+
133+
// Scroll to first form message, if not visible
134+
let el: HTMLElement | null;
135+
el = Form.querySelector(selector) as HTMLElement | null;
136+
if (!el) return;
137+
// Find underlying element if it is a FormGroup element
138+
el = el.querySelector(selector) ?? el;
139+
140+
const nav = options.stickyNavbar
141+
? (document.querySelector(options.stickyNavbar) as HTMLElement)
142+
: null;
143+
144+
if (typeof options.scrollToError != 'string') {
145+
el.scrollIntoView(options.scrollToError);
146+
} else if (!isElementInViewport(el, nav?.offsetHeight ?? 0)) {
147+
scrollToAndCenter(el, undefined, options.scrollToError);
148+
}
149+
150+
// Don't focus on the element if on mobile, it will open the keyboard
151+
// and probably hide the error message.
152+
if (!Form_shouldAutoFocus(navigator.userAgent)) return;
153+
154+
let focusEl;
155+
focusEl = el;
156+
157+
if (
158+
!['INPUT', 'SELECT', 'BUTTON', 'TEXTAREA'].includes(focusEl.tagName)
159+
) {
160+
focusEl = focusEl.querySelector<HTMLElement>(
161+
'input:not([type="hidden"]):not(.flatpickr-input), select, textarea'
162+
);
163+
}
164+
165+
if (focusEl) {
166+
try {
167+
focusEl.focus({ preventScroll: true });
168+
if (options.selectErrorText && focusEl.tagName == 'INPUT') {
169+
(focusEl as HTMLInputElement).select();
170+
}
171+
} catch (err) {
172+
// Some hidden inputs like from flatpickr cannot be focused.
173+
}
174+
}
175+
};
176+
177+
//#endregion
178+
179+
{
180+
ErrorTextEvents_addErrorTextListeners();
181+
182+
const completed = (cancelled: boolean) => {
183+
Timers_clear();
184+
if (!cancelled) setTimeout(Form_scrollToFirstError);
185+
};
186+
187+
onDestroy(() => {
188+
ErrorTextEvents_removeErrorTextListeners();
189+
completed(true);
190+
});
191+
192+
if (!_initialized) {
193+
afterNavigate((nav) => {
194+
if (nav.type != 'enter') Timers_clearAll();
195+
});
196+
_initialized = true;
197+
}
198+
199+
return {
200+
submitting: () => {
201+
Timers_start();
202+
},
203+
204+
completed,
205+
206+
scrollToFirstError: () => {
207+
setTimeout(Form_scrollToFirstError);
208+
},
209+
210+
isSubmitting: () =>
211+
state === FetchStatus.Submitting || state === FetchStatus.Delayed
212+
};
213+
}
214+
}

0 commit comments

Comments
 (0)