@kagayoi/support-extension 1.0.6

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,647 @@
1
+ (() => {
2
+ "use strict"
3
+
4
+ const DEFAULT_API_BASE = "https://support.kagayoi.com"
5
+ const SESSION_KEY = "kagayoi-support-session"
6
+ const API_TIMEOUT_MS = 15_000
7
+ const FORM_STYLESHEET_URL = bundledStylesheetUrl("kagayoi-support-form.css")
8
+ const POPUP_STYLESHEET_URL = bundledStylesheetUrl("kagayoi-support-popup.css")
9
+ const FIREFOX_OPTIONAL_CONTACT_DATA_PERMISSIONS = ["personalCommunications"]
10
+ const CHANNELS = new Set(["web", "desktop", "extension", "other"])
11
+ const STORAGE_SCOPES = new Set(["session", "local"])
12
+ const CATEGORIES = [
13
+ ["question", "使い方・ご質問"],
14
+ ["bug", "不具合のご報告"],
15
+ ["feature", "機能のご要望"],
16
+ ["billing", "料金・ご依頼"],
17
+ ["other", "その他"],
18
+ ]
19
+ const ERROR_LABELS = {
20
+ "invalid email": "メールアドレスを確認してください。",
21
+ "too many requests": "短時間に送信が集中しています。少し時間を置いてからお試しください。",
22
+ "email unavailable": "確認メールを送信できませんでした。時間を置いてからお試しください。",
23
+ "invalid code": "確認コードが正しくありません。",
24
+ "code expired or locked": "確認コードの期限が切れたか、入力回数の上限に達しました。新しいコードを取得してください。",
25
+ "code already used": "この確認コードは使用済みです。新しいコードを取得してください。",
26
+ "authentication required": "認証の有効期限が切れました。確認コードを取得し直してください。",
27
+ "invalid session": "認証の有効期限が切れました。確認コードを取得し直してください。",
28
+ "invalid ticket": "入力内容を確認してください。",
29
+ "invalid ticket metadata": "入力内容が長すぎます。",
30
+ "unknown product": "お問い合わせ先を確認できませんでした。",
31
+ "request too large": "お問い合わせ内容が長すぎます。",
32
+ "origin not allowed": "このサイトからは現在送信できません。",
33
+ }
34
+
35
+ function bundledStylesheetUrl(fileName) {
36
+ const api = typeof browser !== "undefined" ? browser : typeof chrome !== "undefined" ? chrome : null
37
+ if (api?.runtime?.getURL) return api.runtime.getURL(`src/shared/${fileName}`)
38
+
39
+ const script = Array.from(document.scripts).find(({ src }) =>
40
+ /(?:^|\/)(?:contact-form|kagayoi-support-popup)\.js(?:[?#]|$)/.test(src),
41
+ )
42
+ return new URL(fileName, script?.src || document.baseURI).href
43
+ }
44
+
45
+ // shadow DOM の中身は innerHTML 代入で組まない。このファイルは Chrome 拡張へ同梱する正本で、
46
+ // AMO の静的解析が innerHTML 代入を UNSAFE_VAR_ASSIGNMENT として弾き、拡張側リポジトリにも
47
+ // 「innerHTML 不使用」の契約があるため。テンプレートは静的文字列のみで、利用者入力は通さない。
48
+ function replaceShadowContent(shadowRoot, stylesheetUrl, markup) {
49
+ const parsed = new DOMParser().parseFromString(markup, "text/html")
50
+ const stylesheet = document.createElement("link")
51
+ stylesheet.rel = "stylesheet"
52
+ stylesheet.href = stylesheetUrl
53
+ // MV3 の style-src 'self' では、style要素もConstructable StylesheetのCSS文字列もinline扱いになる。
54
+ // 同梱した外部CSSだけを読み込み、Shadow DOMへinline styleを一度も入れない。
55
+ shadowRoot.replaceChildren(stylesheet, ...parsed.body.childNodes)
56
+ }
57
+
58
+ let instanceCount = 0
59
+
60
+ class KagayoiContactForm extends HTMLElement {
61
+ constructor() {
62
+ super()
63
+ this.attachShadow({ mode: "open" })
64
+ this.instanceId = `kagayoi-support-${++instanceCount}`
65
+ this.codeRequestedFor = ""
66
+ this.ticketIdempotencyKey = ""
67
+ this.busy = false
68
+ }
69
+
70
+ connectedCallback() {
71
+ if (this.shadowRoot.childElementCount) return
72
+ this.productId = this.getAttribute("product-id")?.trim() ?? ""
73
+ this.productName = this.getAttribute("product-name")?.trim() || "このサイト"
74
+ this.apiBase = (this.getAttribute("api-base")?.trim() || DEFAULT_API_BASE).replace(/\/$/, "")
75
+ const requestedChannel = this.getAttribute("channel")?.trim().toLowerCase() || "web"
76
+ const requestedStorage = this.getAttribute("storage")?.trim().toLowerCase() || "session"
77
+ this.channel = CHANNELS.has(requestedChannel) ? requestedChannel : "web"
78
+ this.storageScope = STORAGE_SCOPES.has(requestedStorage) ? requestedStorage : "session"
79
+ this.appVersion = this.optionalAttribute("app-version", 100)
80
+ this.osVersion = this.optionalAttribute("os-version", 200)
81
+ this.locale = this.optionalAttribute("locale", 40) || navigator.language?.slice(0, 40) || null
82
+ this.diagnostics = this.optionalAttribute("diagnostics", 20000)
83
+ this.render()
84
+ this.bindEvents()
85
+ this.syncAuthState()
86
+ }
87
+
88
+ render() {
89
+ const id = this.instanceId
90
+ replaceShadowContent(this.shadowRoot, FORM_STYLESHEET_URL, `
91
+ <form class="panel" novalidate aria-label="お問い合わせフォーム">
92
+ <p class="intro"><strong class="product-name"></strong>について、不具合・ご質問・ご要望・ご依頼を送信できます。返信先の確認のため、初回はメールで届く6桁のコードを入力してください。</p>
93
+ <div class="grid">
94
+ <div class="field">
95
+ <label for="${id}-name">お名前 <span class="optional">(任意)</span></label>
96
+ <input id="${id}-name" name="customerName" type="text" autocomplete="name" maxlength="100">
97
+ </div>
98
+ <div class="field">
99
+ <label for="${id}-email">メールアドレス</label>
100
+ <input id="${id}-email" name="email" type="email" autocomplete="email" inputmode="email" maxlength="254" required>
101
+ </div>
102
+ <div class="field field--full">
103
+ <label for="${id}-category">お問い合わせ種別</label>
104
+ <select id="${id}-category" name="category" required>
105
+ ${CATEGORIES.map(([value, label]) => `<option value="${value}">${label}</option>`).join("")}
106
+ </select>
107
+ </div>
108
+ <div class="field field--full">
109
+ <label for="${id}-subject">件名</label>
110
+ <input id="${id}-subject" name="subject" type="text" maxlength="160" required>
111
+ </div>
112
+ <div class="field field--full">
113
+ <label for="${id}-description">お問い合わせ内容</label>
114
+ <textarea id="${id}-description" name="description" maxlength="10000" required></textarea>
115
+ </div>
116
+ <div class="verification" hidden>
117
+ <div class="field">
118
+ <label for="${id}-code">6桁の確認コード</label>
119
+ <input id="${id}-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6">
120
+ </div>
121
+ <p>コードは10分間有効です。届かない場合は迷惑メールフォルダーもご確認ください。<br><button class="resend" type="button">確認コードを再送する</button></p>
122
+ </div>
123
+ </div>
124
+ <div class="actions">
125
+ <button class="submit" type="submit">確認コードを送信</button>
126
+ <p class="auth-note" hidden></p>
127
+ </div>
128
+ <p class="status" role="status" aria-live="polite"></p>
129
+ </form>
130
+ <section class="success" hidden tabindex="-1" aria-live="polite">
131
+ <h2>お問い合わせを受け付けました</h2>
132
+ <p>受付番号を控えてください。</p>
133
+ <p class="reference"></p>
134
+ <p>同じメールアドレスでログインすると、返信と対応状況を <a href="https://support.kagayoi.com/tickets" target="_blank" rel="noopener">Kagayoi Support</a> で確認できます。</p>
135
+ <button class="again" type="button">別のお問い合わせを送る</button>
136
+ </section>
137
+ `)
138
+ this.form = this.shadowRoot.querySelector("form")
139
+ this.success = this.shadowRoot.querySelector(".success")
140
+ this.email = this.form.elements.email
141
+ this.code = this.form.elements.code
142
+ this.verification = this.shadowRoot.querySelector(".verification")
143
+ this.submit = this.shadowRoot.querySelector(".submit")
144
+ this.resend = this.shadowRoot.querySelector(".resend")
145
+ this.status = this.shadowRoot.querySelector(".status")
146
+ this.authNote = this.shadowRoot.querySelector(".auth-note")
147
+ this.shadowRoot.querySelector(".product-name").textContent = this.productName
148
+ this.form.setAttribute("aria-label", `${this.productName}へのお問い合わせフォーム`)
149
+ }
150
+
151
+ bindEvents() {
152
+ this.form.addEventListener("submit", (event) => {
153
+ event.preventDefault()
154
+ void this.handleSubmit()
155
+ })
156
+ this.email.addEventListener("input", () => {
157
+ if (this.codeRequestedFor && this.normalizedEmail() !== this.codeRequestedFor) this.resetVerification()
158
+ this.syncAuthState()
159
+ })
160
+ this.resend.addEventListener("click", () => void this.requestCode())
161
+ this.shadowRoot.querySelector(".again").addEventListener("click", () => this.resetForm())
162
+ }
163
+
164
+ async handleSubmit() {
165
+ if (this.busy || !this.productId) return
166
+ if (!this.validateTicketFields()) return
167
+ if (!await this.ensureDataCollectionConsent()) return
168
+ const session = this.sessionForCurrentEmail()
169
+ if (session) {
170
+ await this.createTicket(session.accessToken)
171
+ return
172
+ }
173
+ if (this.codeRequestedFor === this.normalizedEmail()) {
174
+ if (!this.code.checkValidity()) {
175
+ this.code.reportValidity()
176
+ return
177
+ }
178
+ await this.verifyAndCreate()
179
+ return
180
+ }
181
+ await this.requestCode(true)
182
+ }
183
+
184
+ validateTicketFields() {
185
+ for (const control of [this.email, this.form.elements.category, this.form.elements.subject, this.form.elements.description]) {
186
+ if (!control.checkValidity()) {
187
+ control.reportValidity()
188
+ return false
189
+ }
190
+ }
191
+ return true
192
+ }
193
+
194
+ async requestCode(consentChecked = false) {
195
+ if (this.busy || !this.email.checkValidity()) {
196
+ if (!this.email.checkValidity()) this.email.reportValidity()
197
+ return
198
+ }
199
+ if (!consentChecked && !await this.ensureDataCollectionConsent()) return
200
+ const email = this.normalizedEmail()
201
+ await this.runBusy(async () => {
202
+ const data = await this.api("/api/auth/request", { method: "POST", body: { email } })
203
+ this.codeRequestedFor = email
204
+ this.verification.hidden = false
205
+ this.code.required = true
206
+ if (data.devCode && /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(this.apiBase)) this.code.value = data.devCode
207
+ this.submit.textContent = "認証して問い合わせを送信"
208
+ this.setStatus(`${email} へ確認コードを送りました。`, "success")
209
+ this.code.focus()
210
+ })
211
+ }
212
+
213
+ async verifyAndCreate() {
214
+ const email = this.normalizedEmail()
215
+ await this.runBusy(async () => {
216
+ const session = await this.api("/api/auth/verify", {
217
+ method: "POST",
218
+ body: { email, code: this.code.value.trim() },
219
+ })
220
+ this.storeSession(session)
221
+ await this.createTicket(session.accessToken, false)
222
+ })
223
+ }
224
+
225
+ async createTicket(accessToken, manageBusy = true) {
226
+ const action = async () => {
227
+ this.ticketIdempotencyKey ||= crypto.randomUUID()
228
+ const result = await this.api("/api/tickets", {
229
+ method: "POST",
230
+ token: accessToken,
231
+ idempotencyKey: this.ticketIdempotencyKey,
232
+ body: {
233
+ productId: this.productId,
234
+ customerName: this.form.elements.customerName.value.trim() || null,
235
+ category: this.form.elements.category.value,
236
+ subject: this.form.elements.subject.value.trim(),
237
+ description: this.form.elements.description.value.trim(),
238
+ channel: this.channel,
239
+ appVersion: this.appVersion,
240
+ osVersion: this.osVersion,
241
+ locale: this.locale,
242
+ diagnostics: this.diagnostics,
243
+ },
244
+ })
245
+ this.ticketIdempotencyKey = ""
246
+ this.showSuccess(result.ticket.reference)
247
+ }
248
+ if (manageBusy) await this.runBusy(action)
249
+ else await action()
250
+ }
251
+
252
+ async ensureDataCollectionConsent() {
253
+ if (!this.hasAttribute("firefox-data-consent")) return true
254
+ const api = globalThis.browser ?? globalThis.chrome
255
+ let extensionOrigin = ""
256
+ try {
257
+ extensionOrigin = api?.runtime?.getURL?.("") || ""
258
+ } catch {}
259
+ if (!extensionOrigin.startsWith("moz-extension://")) return true
260
+
261
+ try {
262
+ // submit / resend の user-activated handler 内で最初の非同期 API として呼ぶ。
263
+ // 既に許可済みなら Firefox はプロンプトなしで true を返す。
264
+ const granted = await api.permissions.request({
265
+ data_collection: FIREFOX_OPTIONAL_CONTACT_DATA_PERMISSIONS,
266
+ })
267
+ if (granted) return true
268
+ this.setStatus("お問い合わせ情報の送信には Firefox の許可が必要です。", "error")
269
+ } catch {
270
+ this.setStatus("Firefox のデータ送信許可を確認できませんでした。", "error")
271
+ }
272
+ return false
273
+ }
274
+
275
+ async api(path, options) {
276
+ const headers = { "Content-Type": "application/json" }
277
+ if (options.token) headers.Authorization = `Bearer ${options.token}`
278
+ if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey
279
+ let response
280
+ try {
281
+ response = await fetch(`${this.apiBase}${path}`, {
282
+ method: options.method,
283
+ credentials: "omit",
284
+ headers,
285
+ body: JSON.stringify(options.body),
286
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
287
+ })
288
+ } catch {
289
+ throw new Error("network")
290
+ }
291
+ const data = await response.json().catch(() => ({}))
292
+ if (!response.ok) {
293
+ const error = new Error(data.error || "request failed")
294
+ error.status = response.status
295
+ throw error
296
+ }
297
+ return data
298
+ }
299
+
300
+ async runBusy(action) {
301
+ this.busy = true
302
+ this.submit.disabled = true
303
+ this.resend.disabled = true
304
+ this.setStatus("送信しています…")
305
+ try {
306
+ await action()
307
+ } catch (error) {
308
+ if (error.status === 401) {
309
+ this.clearSession()
310
+ this.resetVerification()
311
+ }
312
+ this.setStatus(this.errorLabel(error), "error")
313
+ } finally {
314
+ this.busy = false
315
+ this.submit.disabled = false
316
+ this.resend.disabled = false
317
+ this.syncAuthState()
318
+ }
319
+ }
320
+
321
+ showSuccess(reference) {
322
+ this.shadowRoot.querySelector(".reference").textContent = reference
323
+ this.form.hidden = true
324
+ this.success.hidden = false
325
+ this.success.focus?.()
326
+ this.dispatchEvent(new CustomEvent("kagayoi-support-submitted", {
327
+ bubbles: true,
328
+ composed: true,
329
+ detail: { reference },
330
+ }))
331
+ }
332
+
333
+ resetForm() {
334
+ const email = this.normalizedEmail()
335
+ this.form.reset()
336
+ this.email.value = email
337
+ this.resetVerification()
338
+ this.success.hidden = true
339
+ this.form.hidden = false
340
+ this.setStatus("")
341
+ this.syncAuthState()
342
+ this.form.elements.subject.focus()
343
+ }
344
+
345
+ resetVerification() {
346
+ this.codeRequestedFor = ""
347
+ this.code.value = ""
348
+ this.code.required = false
349
+ this.verification.hidden = true
350
+ }
351
+
352
+ syncAuthState() {
353
+ if (this.codeRequestedFor) return
354
+ const session = this.sessionForCurrentEmail()
355
+ this.submit.textContent = session ? "問い合わせを送信" : "確認コードを送信"
356
+ this.authNote.hidden = !session
357
+ this.authNote.textContent = session ? `${session.email} は認証済みです。` : ""
358
+ }
359
+
360
+ normalizedEmail() {
361
+ return this.email.value.trim().toLowerCase()
362
+ }
363
+
364
+ sessionForCurrentEmail() {
365
+ const session = this.readSession()
366
+ return session?.email === this.normalizedEmail() ? session : null
367
+ }
368
+
369
+ readSession() {
370
+ try {
371
+ const session = JSON.parse(this.storageArea()?.getItem(SESSION_KEY) || "null")
372
+ if (!session?.accessToken || !session.email || Number(session.expiresAt) <= Math.floor(Date.now() / 1000)) {
373
+ this.clearSession()
374
+ return null
375
+ }
376
+ return session
377
+ } catch {
378
+ this.clearSession()
379
+ return null
380
+ }
381
+ }
382
+
383
+ storeSession(session) {
384
+ try {
385
+ this.storageArea()?.setItem(SESSION_KEY, JSON.stringify({
386
+ accessToken: session.accessToken,
387
+ expiresAt: session.expiresAt,
388
+ email: String(session.email).toLowerCase(),
389
+ }))
390
+ } catch {
391
+ // Storage can be unavailable in hardened browsers. The current submission can still continue.
392
+ }
393
+ }
394
+
395
+ clearSession() {
396
+ try {
397
+ this.storageArea()?.removeItem(SESSION_KEY)
398
+ } catch {
399
+ // Nothing else is required when browser storage is unavailable.
400
+ }
401
+ }
402
+
403
+ setStatus(message, kind = "") {
404
+ this.status.textContent = message
405
+ this.status.dataset.kind = kind
406
+ }
407
+
408
+ optionalAttribute(name, maxLength) {
409
+ const value = this.getAttribute(name)?.trim()
410
+ return value ? value.slice(0, maxLength) : null
411
+ }
412
+
413
+ storageArea() {
414
+ try {
415
+ return this.storageScope === "local" ? window.localStorage : window.sessionStorage
416
+ } catch {
417
+ return null
418
+ }
419
+ }
420
+
421
+ errorLabel(error) {
422
+ if (error.message === "network") return "通信できませんでした。接続を確認して、もう一度お試しください。"
423
+ return ERROR_LABELS[error.message] || "送信できませんでした。時間を置いてからお試しください。"
424
+ }
425
+ }
426
+
427
+ let popupCount = 0
428
+ let documentScrollLockCount = 0
429
+ let documentScrollRestore = []
430
+ let documentScrollPosition = { left: 0, top: 0 }
431
+
432
+ function rememberInlineStyles(element, properties) {
433
+ return {
434
+ element,
435
+ properties: properties.map((property) => ({
436
+ property,
437
+ value: element.style.getPropertyValue(property),
438
+ priority: element.style.getPropertyPriority(property),
439
+ })),
440
+ }
441
+ }
442
+
443
+ function applyLockedStyles(element, styles) {
444
+ for (const [property, value] of Object.entries(styles)) {
445
+ element.style.setProperty(property, value, "important")
446
+ }
447
+ }
448
+
449
+ function lockDocumentScroll() {
450
+ const root = document.documentElement
451
+ const body = document.body
452
+ if (!root || !body) return
453
+ documentScrollLockCount += 1
454
+ if (documentScrollLockCount !== 1) return
455
+
456
+ const viewportWidth = Math.max(1, window.innerWidth || root.clientWidth || body.clientWidth || 1)
457
+ const viewportHeight = Math.max(1, window.innerHeight || root.clientHeight || body.clientHeight || 1)
458
+ const lockedWidth = `${viewportWidth}px`
459
+ const lockedHeight = `${viewportHeight}px`
460
+ documentScrollPosition = {
461
+ left: Number.isFinite(window.scrollX) ? window.scrollX : 0,
462
+ top: Number.isFinite(window.scrollY) ? window.scrollY : 0,
463
+ }
464
+ documentScrollRestore = [
465
+ rememberInlineStyles(root, [
466
+ "overflow",
467
+ "width",
468
+ "min-width",
469
+ "max-width",
470
+ "height",
471
+ "min-height",
472
+ "max-height",
473
+ "scrollbar-width",
474
+ "scrollbar-color",
475
+ ]),
476
+ rememberInlineStyles(body, [
477
+ "overflow",
478
+ "position",
479
+ "top",
480
+ "right",
481
+ "left",
482
+ "width",
483
+ "min-width",
484
+ "max-width",
485
+ "height",
486
+ "min-height",
487
+ "max-height",
488
+ "scrollbar-width",
489
+ "scrollbar-color",
490
+ ]),
491
+ ]
492
+
493
+ // Chrome の action popup は documentElement.scrollHeight が上限 (600px) を超えると、
494
+ // overflow:hidden でもブラウザ側の外スクロールバーを強制する。現在の viewport 寸法へ
495
+ // レイアウト自体を固定し、body を通常フローから外すことで dialog の1本だけにする。
496
+ // fixed body の幅解決は Chromium のビルド差があるため、左右位置だけでなく pixel 幅も固定する。
497
+ applyLockedStyles(root, {
498
+ overflow: "hidden",
499
+ width: lockedWidth,
500
+ "min-width": lockedWidth,
501
+ "max-width": lockedWidth,
502
+ height: lockedHeight,
503
+ "min-height": lockedHeight,
504
+ "max-height": lockedHeight,
505
+ "scrollbar-width": "none",
506
+ "scrollbar-color": "transparent transparent",
507
+ })
508
+ applyLockedStyles(body, {
509
+ overflow: "hidden",
510
+ position: "fixed",
511
+ top: "0",
512
+ right: "0",
513
+ left: "0",
514
+ width: lockedWidth,
515
+ "min-width": lockedWidth,
516
+ "max-width": lockedWidth,
517
+ height: lockedHeight,
518
+ "min-height": lockedHeight,
519
+ "max-height": lockedHeight,
520
+ "scrollbar-width": "none",
521
+ "scrollbar-color": "transparent transparent",
522
+ })
523
+ }
524
+
525
+ function unlockDocumentScroll() {
526
+ if (documentScrollLockCount === 0) return
527
+ documentScrollLockCount -= 1
528
+ if (documentScrollLockCount !== 0) return
529
+
530
+ for (const { element, properties } of documentScrollRestore) {
531
+ for (const { property, value, priority } of properties) {
532
+ if (value) element.style.setProperty(property, value, priority)
533
+ else element.style.removeProperty(property)
534
+ }
535
+ }
536
+ documentScrollRestore = []
537
+ if (documentScrollPosition.left || documentScrollPosition.top) {
538
+ try {
539
+ window.scrollTo(documentScrollPosition.left, documentScrollPosition.top)
540
+ } catch {
541
+ // Embedded or hardened contexts can reject programmatic scrolling.
542
+ }
543
+ }
544
+ documentScrollPosition = { left: 0, top: 0 }
545
+ }
546
+
547
+ class KagayoiContactPopup extends HTMLElement {
548
+ constructor() {
549
+ super()
550
+ this.attachShadow({ mode: "open" })
551
+ }
552
+
553
+ connectedCallback() {
554
+ if (this.shadowRoot.childElementCount) return
555
+ const popupId = `kagayoi-support-popup-${++popupCount}`
556
+ replaceShadowContent(this.shadowRoot, POPUP_STYLESHEET_URL, `
557
+ <button class="trigger" type="button" aria-haspopup="dialog" aria-controls="${popupId}">お問い合わせ</button>
558
+ <dialog id="${popupId}" aria-labelledby="${popupId}-title">
559
+ <div class="shell">
560
+ <header class="header">
561
+ <h2 class="title" id="${popupId}-title">お問い合わせ</h2>
562
+ <button class="close" type="button" aria-label="閉じる">×</button>
563
+ </header>
564
+ <div class="form-host"></div>
565
+ </div>
566
+ </dialog>
567
+ `)
568
+ this.trigger = this.shadowRoot.querySelector(".trigger")
569
+ this.dialog = this.shadowRoot.querySelector("dialog")
570
+ this.form = document.createElement("kagayoi-contact-form")
571
+ this.form.setAttribute("channel", "extension")
572
+ this.form.setAttribute("storage", "local")
573
+ this.trigger.textContent = this.getAttribute("button-label")?.trim() || "お問い合わせ"
574
+ this.trigger.hidden = this.hasAttribute("hide-trigger")
575
+ this.shadowRoot.querySelector(".title").textContent = this.getAttribute("dialog-title")?.trim() || "お問い合わせ"
576
+ for (const name of ["product-id", "product-name", "api-base", "app-version", "os-version", "locale", "diagnostics", "firefox-data-consent"]) {
577
+ if (this.hasAttribute(name)) this.form.setAttribute(name, this.getAttribute(name))
578
+ }
579
+ this.shadowRoot.querySelector(".form-host").append(this.form)
580
+ this.trigger.addEventListener("click", () => this.open())
581
+ this.shadowRoot.querySelector(".close").addEventListener("click", () => this.close())
582
+ this.dialog.addEventListener("click", (event) => {
583
+ if (event.target === this.dialog) this.close()
584
+ })
585
+ this.dialog.addEventListener("close", () => {
586
+ this.finishClose()
587
+ })
588
+ if (this.hasAttribute("open")) {
589
+ queueMicrotask(() => {
590
+ if (this.isConnected) this.open()
591
+ })
592
+ }
593
+ }
594
+
595
+ disconnectedCallback() {
596
+ this.releaseDocumentScrollLock()
597
+ }
598
+
599
+ open(returnFocusTo = this.trigger) {
600
+ if (!this.isConnected || !this.dialog || this.dialog.open) return
601
+ this.returnFocusTo = returnFocusTo
602
+ this.acquireDocumentScrollLock()
603
+ try {
604
+ if (typeof this.dialog.showModal === "function") this.dialog.showModal()
605
+ else this.dialog.setAttribute("open", "")
606
+ } catch (error) {
607
+ this.releaseDocumentScrollLock()
608
+ throw error
609
+ }
610
+ queueMicrotask(() => this.form?.shadowRoot?.querySelector('input[name="email"]')?.focus())
611
+ }
612
+
613
+ close() {
614
+ if (!this.dialog?.open) return
615
+ if (typeof this.dialog.close === "function") this.dialog.close()
616
+ else this.dialog.removeAttribute("open")
617
+ this.finishClose()
618
+ }
619
+
620
+ finishClose() {
621
+ if (!this.documentScrollLocked) return
622
+ this.releaseDocumentScrollLock()
623
+ this.restoreFocus()
624
+ }
625
+
626
+ restoreFocus() {
627
+ const target = this.returnFocusTo?.isConnected ? this.returnFocusTo : this.trigger
628
+ target?.focus()
629
+ this.returnFocusTo = this.trigger
630
+ }
631
+
632
+ acquireDocumentScrollLock() {
633
+ if (this.documentScrollLocked) return
634
+ this.documentScrollLocked = true
635
+ lockDocumentScroll()
636
+ }
637
+
638
+ releaseDocumentScrollLock() {
639
+ if (!this.documentScrollLocked) return
640
+ this.documentScrollLocked = false
641
+ unlockDocumentScroll()
642
+ }
643
+ }
644
+
645
+ if (!customElements.get("kagayoi-contact-form")) customElements.define("kagayoi-contact-form", KagayoiContactForm)
646
+ if (!customElements.get("kagayoi-contact-popup")) customElements.define("kagayoi-contact-popup", KagayoiContactPopup)
647
+ })()
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@kagayoi/support-extension",
3
+ "version": "1.0.6",
4
+ "description": "Kagayoi製Chrome拡張へ同梱する問い合わせフォームとサポートフッター",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "author": "Kagayoi",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/1llum1n4t1s/Kagayoi.Support.git",
11
+ "directory": "clients/chrome-extension"
12
+ },
13
+ "homepage": "https://support.kagayoi.com",
14
+ "keywords": [
15
+ "chrome-extension",
16
+ "firefox-extension",
17
+ "manifest-v3",
18
+ "support"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "dist/",
26
+ "README.md"
27
+ ],
28
+ "bin": {
29
+ "kagayoi-support-sync": "bin/kagayoi-support-sync.mjs"
30
+ },
31
+ "scripts": {
32
+ "build": "node ../../scripts/build-extension-package.mjs",
33
+ "prepack": "pnpm run build"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }