forked from remix-run/react-router
-
Notifications
You must be signed in to change notification settings - Fork 0
/
dom.ts
309 lines (270 loc) · 9.06 KB
/
dom.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
import type {
FormEncType,
HTMLFormMethod,
RelativeRoutingType,
} from "@remix-run/router";
import { stripBasename, UNSAFE_warning as warning } from "@remix-run/router";
export const defaultMethod: HTMLFormMethod = "get";
const defaultEncType: FormEncType = "application/x-www-form-urlencoded";
export function isHtmlElement(object: any): object is HTMLElement {
return object != null && typeof object.tagName === "string";
}
export function isButtonElement(object: any): object is HTMLButtonElement {
return isHtmlElement(object) && object.tagName.toLowerCase() === "button";
}
export function isFormElement(object: any): object is HTMLFormElement {
return isHtmlElement(object) && object.tagName.toLowerCase() === "form";
}
export function isInputElement(object: any): object is HTMLInputElement {
return isHtmlElement(object) && object.tagName.toLowerCase() === "input";
}
type LimitedMouseEvent = Pick<
MouseEvent,
"button" | "metaKey" | "altKey" | "ctrlKey" | "shiftKey"
>;
function isModifiedEvent(event: LimitedMouseEvent) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
export function shouldProcessLinkClick(
event: LimitedMouseEvent,
target?: string
) {
return (
event.button === 0 && // Ignore everything but left clicks
(!target || target === "_self") && // Let browser handle "target=_blank" etc.
!isModifiedEvent(event) // Ignore clicks with modifier keys
);
}
export type ParamKeyValuePair = [string, string];
export type URLSearchParamsInit =
| string
| ParamKeyValuePair[]
| Record<string, string | string[]>
| URLSearchParams;
/**
* Creates a URLSearchParams object using the given initializer.
*
* This is identical to `new URLSearchParams(init)` except it also
* supports arrays as values in the object form of the initializer
* instead of just strings. This is convenient when you need multiple
* values for a given key, but don't want to use an array initializer.
*
* For example, instead of:
*
* let searchParams = new URLSearchParams([
* ['sort', 'name'],
* ['sort', 'price']
* ]);
*
* you can do:
*
* let searchParams = createSearchParams({
* sort: ['name', 'price']
* });
*/
export function createSearchParams(
init: URLSearchParamsInit = ""
): URLSearchParams {
return new URLSearchParams(
typeof init === "string" ||
Array.isArray(init) ||
init instanceof URLSearchParams
? init
: Object.keys(init).reduce((memo, key) => {
let value = init[key];
return memo.concat(
Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
);
}, [] as ParamKeyValuePair[])
);
}
export function getSearchParamsForLocation(
locationSearch: string,
defaultSearchParams: URLSearchParams | null
) {
let searchParams = createSearchParams(locationSearch);
if (defaultSearchParams) {
// Use `defaultSearchParams.forEach(...)` here instead of iterating of
// `defaultSearchParams.keys()` to work-around a bug in Firefox related to
// web extensions. Relevant Bugzilla tickets:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1414602
// https://bugzilla.mozilla.org/show_bug.cgi?id=1023984
defaultSearchParams.forEach((_, key) => {
if (!searchParams.has(key)) {
defaultSearchParams.getAll(key).forEach((value) => {
searchParams.append(key, value);
});
}
});
}
return searchParams;
}
// Thanks https://github.com/sindresorhus/type-fest!
type JsonObject = { [Key in string]: JsonValue } & {
[Key in string]?: JsonValue | undefined;
};
type JsonArray = JsonValue[] | readonly JsonValue[];
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export type SubmitTarget =
| HTMLFormElement
| HTMLButtonElement
| HTMLInputElement
| FormData
| URLSearchParams
| JsonValue
| null;
// One-time check for submitter support
let _formDataSupportsSubmitter: boolean | null = null;
function isFormDataSubmitterSupported() {
if (_formDataSupportsSubmitter === null) {
try {
new FormData(
document.createElement("form"),
// @ts-expect-error if FormData supports the submitter parameter, this will throw
0
);
_formDataSupportsSubmitter = false;
} catch (e) {
_formDataSupportsSubmitter = true;
}
}
return _formDataSupportsSubmitter;
}
export interface SubmitOptions {
/**
* The HTTP method used to submit the form. Overrides `<form method>`.
* Defaults to "GET".
*/
method?: HTMLFormMethod;
/**
* The action URL path used to submit the form. Overrides `<form action>`.
* Defaults to the path of the current route.
*/
action?: string;
/**
* The encoding used to submit the form. Overrides `<form encType>`.
* Defaults to "application/x-www-form-urlencoded".
*/
encType?: FormEncType;
/**
* Set `true` to replace the current entry in the browser's history stack
* instead of creating a new one (i.e. stay on "the same page"). Defaults
* to `false`.
*/
replace?: boolean;
/**
* State object to add to the history stack entry for this navigation
*/
state?: any;
/**
* Determines whether the form action is relative to the route hierarchy or
* the pathname. Use this if you want to opt out of navigating the route
* hierarchy and want to instead route based on /-delimited URL segments
*/
relative?: RelativeRoutingType;
/**
* In browser-based environments, prevent resetting scroll after this
* navigation when using the <ScrollRestoration> component
*/
preventScrollReset?: boolean;
}
const supportedFormEncTypes: Set<FormEncType> = new Set([
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
]);
function getFormEncType(encType: string | null) {
if (encType != null && !supportedFormEncTypes.has(encType as FormEncType)) {
warning(
false,
`"${encType}" is not a valid \`encType\` for \`<Form>\`/\`<fetcher.Form>\` ` +
`and will default to "${defaultEncType}"`
);
return null;
}
return encType;
}
export function getFormSubmissionInfo(
target: SubmitTarget,
basename: string
): {
action: string | null;
method: string;
encType: string;
formData: FormData | undefined;
body: any;
} {
let method: string;
let action: string | null;
let encType: string;
let formData: FormData | undefined;
let body: any;
if (isFormElement(target)) {
// When grabbing the action from the element, it will have had the basename
// prefixed to ensure non-JS scenarios work, so strip it since we'll
// re-prefix in the router
let attr = target.getAttribute("action");
action = attr ? stripBasename(attr, basename) : null;
method = target.getAttribute("method") || defaultMethod;
encType = getFormEncType(target.getAttribute("enctype")) || defaultEncType;
formData = new FormData(target);
} else if (
isButtonElement(target) ||
(isInputElement(target) &&
(target.type === "submit" || target.type === "image"))
) {
let form = target.form;
if (form == null) {
throw new Error(
`Cannot submit a <button> or <input type="submit"> without a <form>`
);
}
// <button>/<input type="submit"> may override attributes of <form>
// When grabbing the action from the element, it will have had the basename
// prefixed to ensure non-JS scenarios work, so strip it since we'll
// re-prefix in the router
let attr = target.getAttribute("formaction") || form.getAttribute("action");
action = attr ? stripBasename(attr, basename) : null;
method =
target.getAttribute("formmethod") ||
form.getAttribute("method") ||
defaultMethod;
encType =
getFormEncType(target.getAttribute("formenctype")) ||
getFormEncType(form.getAttribute("enctype")) ||
defaultEncType;
// Build a FormData object populated from a form and submitter
formData = new FormData(form, target);
// If this browser doesn't support the `FormData(el, submitter)` format,
// then tack on the submitter value at the end. This is a lightweight
// solution that is not 100% spec compliant. For complete support in older
// browsers, consider using the `formdata-submitter-polyfill` package
if (!isFormDataSubmitterSupported()) {
let { name, type, value } = target;
if (type === "image") {
let prefix = name ? `${name}.` : "";
formData.append(`${prefix}x`, "0");
formData.append(`${prefix}y`, "0");
} else if (name) {
formData.append(name, value);
}
}
} else if (isHtmlElement(target)) {
throw new Error(
`Cannot submit element that is not <form>, <button>, or ` +
`<input type="submit|image">`
);
} else {
method = defaultMethod;
action = null;
encType = defaultEncType;
body = target;
}
// Send body for <Form encType="text/plain" so we encode it into text
if (formData && encType === "text/plain") {
body = formData;
formData = undefined;
}
return { action, method: method.toLowerCase(), encType, formData, body };
}