Skip to content

Commit e3d753c

Browse files
committed
rework src to allow for various module patterns
1 parent b477d69 commit e3d753c

File tree

3 files changed

+303
-266
lines changed

3 files changed

+303
-266
lines changed

src/auto-check-element-define.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {AutoCheckElement} from './auto-check-element.js'
2+
3+
const root = (typeof globalThis !== 'undefined' ? globalThis : window) as typeof window
4+
try {
5+
root.AutoCheckElement = AutoCheckElement.define()
6+
} catch (e: unknown) {
7+
if (
8+
!(root.DOMException && e instanceof DOMException && e.name === 'NotSupportedError') &&
9+
!(e instanceof ReferenceError)
10+
) {
11+
throw e
12+
}
13+
}
14+
15+
type JSXBase = JSX.IntrinsicElements extends {span: unknown}
16+
? JSX.IntrinsicElements
17+
: Record<string, Record<string, unknown>>
18+
declare global {
19+
interface Window {
20+
AutoCheckElement: typeof AutoCheckElement
21+
}
22+
interface HTMLElementTagNameMap {
23+
'auto-check': AutoCheckElement
24+
}
25+
namespace JSX {
26+
interface IntrinsicElements {
27+
['auto-check']: JSXBase['span'] & Partial<Omit<AutoCheckElement, keyof HTMLElement>>
28+
}
29+
}
30+
}
31+
32+
export default AutoCheckElement
33+
export * from './auto-check-element.js'

src/auto-check-element.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import {debounce} from '@github/mini-throttle'
2+
3+
type Controller =
4+
| AbortController
5+
| {
6+
signal: AbortSignal | null
7+
abort: () => void
8+
}
9+
10+
type State = {
11+
check: (event: Event) => unknown
12+
controller: Controller | null
13+
}
14+
15+
const states = new WeakMap<AutoCheckElement, State>()
16+
17+
export class AutoCheckElement extends HTMLElement {
18+
static define(tag = 'auto-check', registry = customElements) {
19+
registry.define(tag, this)
20+
return this
21+
}
22+
23+
connectedCallback(): void {
24+
const input = this.input
25+
if (!input) return
26+
27+
const checker = debounce(check.bind(null, this), 300)
28+
const state = {check: checker, controller: null}
29+
states.set(this, state)
30+
31+
input.addEventListener('input', setLoadingState)
32+
input.addEventListener('input', checker)
33+
input.autocomplete = 'off'
34+
input.spellcheck = false
35+
}
36+
37+
disconnectedCallback(): void {
38+
const input = this.input
39+
if (!input) return
40+
41+
const state = states.get(this)
42+
if (!state) return
43+
states.delete(this)
44+
45+
input.removeEventListener('input', setLoadingState)
46+
input.removeEventListener('input', state.check)
47+
input.setCustomValidity('')
48+
}
49+
50+
attributeChangedCallback(name: string): void {
51+
if (name === 'required') {
52+
const input = this.input
53+
if (!input) return
54+
input.required = this.required
55+
}
56+
}
57+
58+
static get observedAttributes(): string[] {
59+
return ['required']
60+
}
61+
62+
get input(): HTMLInputElement | null {
63+
return this.querySelector('input')
64+
}
65+
66+
get src(): string {
67+
const src = this.getAttribute('src')
68+
if (!src) return ''
69+
70+
const link = this.ownerDocument!.createElement('a')
71+
link.href = src
72+
return link.href
73+
}
74+
75+
set src(value: string) {
76+
this.setAttribute('src', value)
77+
}
78+
79+
get csrf(): string {
80+
const csrfElement = this.querySelector('[data-csrf]')
81+
return this.getAttribute('csrf') || (csrfElement instanceof HTMLInputElement && csrfElement.value) || ''
82+
}
83+
84+
set csrf(value: string) {
85+
this.setAttribute('csrf', value)
86+
}
87+
88+
get required(): boolean {
89+
return this.hasAttribute('required')
90+
}
91+
92+
set required(required: boolean) {
93+
if (required) {
94+
this.setAttribute('required', '')
95+
} else {
96+
this.removeAttribute('required')
97+
}
98+
}
99+
100+
get csrfField(): string {
101+
return this.getAttribute('csrf-field') || 'authenticity_token'
102+
}
103+
104+
set csrfField(value: string) {
105+
this.setAttribute('csrf-field', value)
106+
}
107+
}
108+
109+
function setLoadingState(event: Event) {
110+
const input = event.currentTarget
111+
if (!(input instanceof HTMLInputElement)) return
112+
113+
const autoCheckElement = input.closest('auto-check')
114+
if (!(autoCheckElement instanceof AutoCheckElement)) return
115+
116+
const src = autoCheckElement.src
117+
const csrf = autoCheckElement.csrf
118+
const state = states.get(autoCheckElement)
119+
120+
// If some attributes are missing we want to exit early and make sure that the element is valid.
121+
if (!src || !csrf || !state) {
122+
return
123+
}
124+
125+
let message = 'Verifying…'
126+
const setValidity = (text: string) => (message = text)
127+
input.dispatchEvent(
128+
new CustomEvent('auto-check-start', {
129+
bubbles: true,
130+
detail: {setValidity},
131+
}),
132+
)
133+
134+
if (autoCheckElement.required) {
135+
input.setCustomValidity(message)
136+
}
137+
}
138+
139+
function makeAbortController() {
140+
if ('AbortController' in window) {
141+
return new AbortController()
142+
}
143+
return {
144+
signal: null,
145+
abort() {
146+
// Do nothing
147+
},
148+
}
149+
}
150+
151+
async function fetchWithNetworkEvents(el: Element, url: string, options: RequestInit): Promise<Response> {
152+
try {
153+
const response = await fetch(url, options)
154+
el.dispatchEvent(new CustomEvent('load'))
155+
el.dispatchEvent(new CustomEvent('loadend'))
156+
return response
157+
} catch (error) {
158+
if ((error as Error).name !== 'AbortError') {
159+
el.dispatchEvent(new CustomEvent('error'))
160+
el.dispatchEvent(new CustomEvent('loadend'))
161+
}
162+
throw error
163+
}
164+
}
165+
166+
async function check(autoCheckElement: AutoCheckElement) {
167+
const input = autoCheckElement.input
168+
if (!input) {
169+
return
170+
}
171+
172+
const csrfField = autoCheckElement.csrfField
173+
const src = autoCheckElement.src
174+
const csrf = autoCheckElement.csrf
175+
const state = states.get(autoCheckElement)
176+
177+
// If some attributes are missing we want to exit early and make sure that the element is valid.
178+
if (!src || !csrf || !state) {
179+
if (autoCheckElement.required) {
180+
input.setCustomValidity('')
181+
}
182+
return
183+
}
184+
185+
if (!input.value.trim()) {
186+
if (autoCheckElement.required) {
187+
input.setCustomValidity('')
188+
}
189+
return
190+
}
191+
192+
const body = new FormData()
193+
body.append(csrfField, csrf)
194+
body.append('value', input.value)
195+
196+
input.dispatchEvent(
197+
new CustomEvent('auto-check-send', {
198+
bubbles: true,
199+
detail: {body},
200+
}),
201+
)
202+
203+
if (state.controller) {
204+
state.controller.abort()
205+
} else {
206+
autoCheckElement.dispatchEvent(new CustomEvent('loadstart'))
207+
}
208+
209+
state.controller = makeAbortController()
210+
211+
try {
212+
const response = await fetchWithNetworkEvents(autoCheckElement, src, {
213+
credentials: 'same-origin',
214+
signal: state.controller.signal,
215+
method: 'POST',
216+
body,
217+
})
218+
if (response.ok) {
219+
processSuccess(response, input, autoCheckElement.required)
220+
} else {
221+
processFailure(response, input, autoCheckElement.required)
222+
}
223+
state.controller = null
224+
input.dispatchEvent(new CustomEvent('auto-check-complete', {bubbles: true}))
225+
} catch (error) {
226+
if ((error as Error).name !== 'AbortError') {
227+
state.controller = null
228+
input.dispatchEvent(new CustomEvent('auto-check-complete', {bubbles: true}))
229+
}
230+
}
231+
}
232+
233+
function processSuccess(response: Response, input: HTMLInputElement, required: boolean) {
234+
if (required) {
235+
input.setCustomValidity('')
236+
}
237+
input.dispatchEvent(
238+
new CustomEvent('auto-check-success', {
239+
bubbles: true,
240+
detail: {
241+
response: response.clone(),
242+
},
243+
}),
244+
)
245+
}
246+
247+
function processFailure(response: Response, input: HTMLInputElement, required: boolean) {
248+
// eslint-disable-next-line i18n-text/no-en
249+
let message = 'Validation failed'
250+
const setValidity = (text: string) => (message = text)
251+
input.dispatchEvent(
252+
new CustomEvent('auto-check-error', {
253+
bubbles: true,
254+
detail: {
255+
response: response.clone(),
256+
setValidity,
257+
},
258+
}),
259+
)
260+
261+
if (required) {
262+
input.setCustomValidity(message)
263+
}
264+
}
265+
266+
export default AutoCheckElement

0 commit comments

Comments
 (0)