fwtoolkit 0.1.0-alpha.1 → 0.1.0-alpha.2
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.
- package/package.json +10 -2
- package/src/basic.js +500 -0
- package/src/blob.js +1 -1
- package/src/content_menu.js +475 -0
- package/src/datatable_bulk.js +208 -0
- package/src/dialog.js +484 -0
- package/src/events.js +9 -0
- package/src/faq_dialog.js +67 -0
- package/src/file/dialog.js +142 -0
- package/src/file/index.js +9 -0
- package/src/file/new_folder_dialog.js +37 -0
- package/src/file/selector.js +263 -0
- package/src/file/templates.js +11 -0
- package/src/file/tools.js +58 -0
- package/src/focus.js +20 -0
- package/src/index.js +61 -12
- package/src/network.js +63 -14
- package/src/overview_menu.js +611 -0
- package/src/settings.js +18 -0
- package/src/templates.js +42 -0
- package/src/user.js +46 -0
- package/src/user_util.js +16 -0
- package/src/worker.js +12 -0
- package/src/ws.js +347 -0
- package/src/file.js +0 -25
- package/src/text.js +0 -44
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fwtoolkit",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"description": "Fidus Writer Toolkit — reusable utilities for Fidus Writer and other applications",
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
|
+
"description": "Fidus Writer Toolkit — reusable utilities and UI helpers for Fidus Writer and other applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "LGPL-3.0-or-later",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://codeberg.org/fiduswriter/fwtoolkit.git"
|
|
10
|
+
},
|
|
7
11
|
"author": "Johannes Wilm",
|
|
8
12
|
"files": [
|
|
9
13
|
"src/",
|
|
@@ -13,5 +17,9 @@
|
|
|
13
17
|
"exports": {
|
|
14
18
|
".": "./src/index.js",
|
|
15
19
|
"./*": "./src/*"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"diff-dom": "^5.2.1",
|
|
23
|
+
"w3c-keyname": "^2.2.8"
|
|
16
24
|
}
|
|
17
25
|
}
|
package/src/basic.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {keyName} from "w3c-keyname"
|
|
2
|
+
|
|
3
|
+
import {ContentMenu} from "./content_menu"
|
|
4
|
+
import {Dialog} from "./dialog"
|
|
5
|
+
import {isActivationEvent} from "./events"
|
|
6
|
+
|
|
7
|
+
/** Creates a styled select with a contentmenu from a select tag.
|
|
8
|
+
* @param select The select-tag which is to be replaced.
|
|
9
|
+
* @param options
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const dropdownSelect = (
|
|
13
|
+
selectDOM,
|
|
14
|
+
{onChange = _newValue => {}, width = false, value = false, button = false}
|
|
15
|
+
) => {
|
|
16
|
+
if (!selectDOM.parentElement) {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
let buttonDOM
|
|
20
|
+
if (button) {
|
|
21
|
+
buttonDOM = button
|
|
22
|
+
selectDOM.parentElement.removeChild(selectDOM) // Remove the <select> from the main dom.
|
|
23
|
+
} else {
|
|
24
|
+
buttonDOM = document.createElement("div")
|
|
25
|
+
buttonDOM.innerHTML =
|
|
26
|
+
'<label></label> <span class="fa fa-caret-down"></span>'
|
|
27
|
+
buttonDOM.classList.add(
|
|
28
|
+
"fw-button",
|
|
29
|
+
"fw-light",
|
|
30
|
+
"fw-large",
|
|
31
|
+
"fw-dropdown"
|
|
32
|
+
)
|
|
33
|
+
if (width) {
|
|
34
|
+
buttonDOM.style.width = Number.isInteger(width)
|
|
35
|
+
? `${width}px`
|
|
36
|
+
: width
|
|
37
|
+
}
|
|
38
|
+
selectDOM.classList.forEach(className =>
|
|
39
|
+
buttonDOM.classList.add(className)
|
|
40
|
+
)
|
|
41
|
+
if (selectDOM.id) {
|
|
42
|
+
buttonDOM.id = selectDOM.id
|
|
43
|
+
}
|
|
44
|
+
selectDOM.parentElement.replaceChild(buttonDOM, selectDOM) // Remove the <select> from the main dom.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
buttonDOM.setAttribute("role", "button")
|
|
48
|
+
buttonDOM.setAttribute("tabindex", "0")
|
|
49
|
+
buttonDOM.setAttribute("aria-haspopup", "true")
|
|
50
|
+
buttonDOM.setAttribute("aria-expanded", "false")
|
|
51
|
+
|
|
52
|
+
const options = Array.from(selectDOM.children)
|
|
53
|
+
if (!options.length) {
|
|
54
|
+
// There are no options, so we only create the button.
|
|
55
|
+
return {
|
|
56
|
+
setValue: () => {},
|
|
57
|
+
getValue: () => false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let selected
|
|
61
|
+
const menu = {
|
|
62
|
+
content: options.map((option, order) => {
|
|
63
|
+
if (option.selected || option.value === value) {
|
|
64
|
+
selected = option
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
title: option.innerHTML,
|
|
68
|
+
type: "action",
|
|
69
|
+
tooltip: option.title || "",
|
|
70
|
+
order,
|
|
71
|
+
action: () => {
|
|
72
|
+
if (!button) {
|
|
73
|
+
buttonDOM.firstElementChild.innerText = option.innerText
|
|
74
|
+
}
|
|
75
|
+
value = option.value || option.dataset.value
|
|
76
|
+
onChange(value)
|
|
77
|
+
menu.content.forEach(item => (item.selected = false))
|
|
78
|
+
menu.content[order].selected = true
|
|
79
|
+
return false
|
|
80
|
+
},
|
|
81
|
+
selected: !!(option.selected || option.dataset.selected)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
if (!selected && !button) {
|
|
86
|
+
selected = selectDOM.firstElementChild
|
|
87
|
+
menu.content[0].selected = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!button) {
|
|
91
|
+
buttonDOM.firstElementChild.innerText = selected.innerText
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
value = selected ? selected.value : false
|
|
95
|
+
|
|
96
|
+
const openMenu = event => {
|
|
97
|
+
if (!isActivationEvent(event)) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
event.preventDefault()
|
|
102
|
+
event.stopPropagation()
|
|
103
|
+
if (buttonDOM.classList.contains("disabled")) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
// Determine menu position
|
|
107
|
+
let menuPos
|
|
108
|
+
if (event.type === "click") {
|
|
109
|
+
menuPos = {X: event.pageX, Y: event.pageY}
|
|
110
|
+
} else {
|
|
111
|
+
// Keyboard event
|
|
112
|
+
const rect = buttonDOM.getBoundingClientRect()
|
|
113
|
+
menuPos = {
|
|
114
|
+
X: rect.left + window.pageXOffset,
|
|
115
|
+
Y: rect.top + window.pageYOffset + rect.height
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
buttonDOM.setAttribute("aria-expanded", "true")
|
|
120
|
+
const contentMenu = new ContentMenu({
|
|
121
|
+
menu,
|
|
122
|
+
menuPos,
|
|
123
|
+
onClose: () => buttonDOM.setAttribute("aria-expanded", "false")
|
|
124
|
+
})
|
|
125
|
+
contentMenu.open()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
buttonDOM.addEventListener("click", openMenu)
|
|
129
|
+
buttonDOM.addEventListener("keydown", openMenu)
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
setValue: newValue => {
|
|
133
|
+
const optionIndex = options.findIndex(
|
|
134
|
+
option => option.value === newValue
|
|
135
|
+
)
|
|
136
|
+
if (optionIndex === undefined) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
menu.content.forEach(item => (item.selected = false))
|
|
140
|
+
menu.content[optionIndex].selected = true
|
|
141
|
+
const option = options[optionIndex]
|
|
142
|
+
if (!button) {
|
|
143
|
+
buttonDOM.firstElementChild.innerText = option.innerText
|
|
144
|
+
}
|
|
145
|
+
value = newValue
|
|
146
|
+
},
|
|
147
|
+
getValue: () => value,
|
|
148
|
+
enable: () => buttonDOM.classList.remove("disabled"),
|
|
149
|
+
disable: () => buttonDOM.classList.add("disabled")
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Checks or unchecks a checkable label. This is used for example for bibliography categories when editing bibliography items.
|
|
154
|
+
* @param label The node who's parent has to be checked or unchecked.
|
|
155
|
+
*/
|
|
156
|
+
export const setCheckableLabel = labelEl => {
|
|
157
|
+
if (labelEl.classList.contains("checked")) {
|
|
158
|
+
labelEl.classList.remove("checked")
|
|
159
|
+
} else {
|
|
160
|
+
labelEl.classList.add("checked")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let messageWaiter = false
|
|
165
|
+
let waitMessage = ""
|
|
166
|
+
/** Cover the page signaling to the user to wait.
|
|
167
|
+
*/
|
|
168
|
+
export const activateWait = (full = false, message = "") => {
|
|
169
|
+
const waitEl = document.getElementById("wait")
|
|
170
|
+
if (message) {
|
|
171
|
+
let messageEl = waitEl.querySelector("span.message")
|
|
172
|
+
if (messageEl) {
|
|
173
|
+
// Another message is already showing. We update directly.
|
|
174
|
+
messageEl.innerText = message
|
|
175
|
+
} else {
|
|
176
|
+
waitMessage = message // We update the message if there is one waiting already
|
|
177
|
+
if (!messageWaiter) {
|
|
178
|
+
messageWaiter = setTimeout(() => {
|
|
179
|
+
messageEl = document.createElement("span")
|
|
180
|
+
messageEl.classList.add("message")
|
|
181
|
+
messageEl.innerText = waitMessage
|
|
182
|
+
waitEl.appendChild(messageEl)
|
|
183
|
+
messageWaiter = false
|
|
184
|
+
}, 2000)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (waitEl) {
|
|
189
|
+
waitEl.classList.add("active")
|
|
190
|
+
if (full) {
|
|
191
|
+
waitEl.classList.add("full")
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Remove the wait cover.
|
|
197
|
+
*/
|
|
198
|
+
export const deactivateWait = () => {
|
|
199
|
+
const waitEl = document.getElementById("wait")
|
|
200
|
+
if (waitEl) {
|
|
201
|
+
waitEl.classList.remove("active")
|
|
202
|
+
waitEl.classList.remove("full")
|
|
203
|
+
}
|
|
204
|
+
const messageEl = waitEl.querySelector("span.message")
|
|
205
|
+
if (messageEl) {
|
|
206
|
+
messageEl.parentElement.removeChild(messageEl)
|
|
207
|
+
}
|
|
208
|
+
if (messageWaiter) {
|
|
209
|
+
clearTimeout(messageWaiter)
|
|
210
|
+
messageWaiter = false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Show a message to the user.
|
|
215
|
+
* @param alertType The type of message that is shown (error, warning, info or success).
|
|
216
|
+
* @param alertMsg The message text.
|
|
217
|
+
*/
|
|
218
|
+
export const addAlert = (alertType, alertMsg) => {
|
|
219
|
+
if (!document.body) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const iconNames = {
|
|
223
|
+
error: "circle-exclamation",
|
|
224
|
+
warning: "circle-exclamation",
|
|
225
|
+
info: "circle-info",
|
|
226
|
+
success: "circle-check"
|
|
227
|
+
}
|
|
228
|
+
if (!document.getElementById("#alerts-outer-wrapper")) {
|
|
229
|
+
document.body.insertAdjacentHTML(
|
|
230
|
+
"beforeend",
|
|
231
|
+
'<div id="alerts-outer-wrapper"><ul id="alerts-wrapper"></ul></div>'
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
const alertsWrapper = document.getElementById("alerts-wrapper")
|
|
235
|
+
alertsWrapper.insertAdjacentHTML(
|
|
236
|
+
"beforeend",
|
|
237
|
+
`<li class="alerts-${alertType} fa-before fa-${iconNames[alertType]}">${alertMsg}</li>`
|
|
238
|
+
)
|
|
239
|
+
const alertBox = alertsWrapper.lastElementChild
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
alertBox.classList.add("visible")
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
alertBox.classList.remove("visible")
|
|
244
|
+
setTimeout(() => alertsWrapper.removeChild(alertBox), 2000)
|
|
245
|
+
}, 4000)
|
|
246
|
+
}, 1)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Used for system mesages
|
|
250
|
+
export const showSystemMessage = (message, buttons = [{type: "close"}]) => {
|
|
251
|
+
const dialog = new Dialog({
|
|
252
|
+
title: gettext("System message"),
|
|
253
|
+
body: `<p>${escapeText(message)}</p>`,
|
|
254
|
+
buttons
|
|
255
|
+
})
|
|
256
|
+
dialog.open()
|
|
257
|
+
return dialog
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Turn milliseconds since epoch (UTC) into a local date string.
|
|
261
|
+
* @param {number} milliseconds Number of milliseconds since epoch (1/1/1970 midnight, UTC).
|
|
262
|
+
* @param {boolean} type 'full' for full date (default), 'sortable-date' for sortable date, 'minutes' for minute accuracy
|
|
263
|
+
*/
|
|
264
|
+
const CACHED_DATES = {
|
|
265
|
+
"sortable-date": {},
|
|
266
|
+
minutes: {},
|
|
267
|
+
full: {}
|
|
268
|
+
}
|
|
269
|
+
export const localizeDate = (milliseconds, type = "full") => {
|
|
270
|
+
if (milliseconds === 0) {
|
|
271
|
+
return ""
|
|
272
|
+
} else if (CACHED_DATES[type][milliseconds]) {
|
|
273
|
+
return CACHED_DATES[type][milliseconds]
|
|
274
|
+
}
|
|
275
|
+
const theDate = new Date(milliseconds)
|
|
276
|
+
let returnValue
|
|
277
|
+
switch (type) {
|
|
278
|
+
case "sortable-date": {
|
|
279
|
+
const yyyy = theDate.getFullYear()
|
|
280
|
+
const mm = theDate.getMonth() + 1
|
|
281
|
+
const dd = theDate.getDate()
|
|
282
|
+
returnValue = `${yyyy}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`
|
|
283
|
+
break
|
|
284
|
+
}
|
|
285
|
+
case "minutes":
|
|
286
|
+
returnValue = theDate.toLocaleString([], {
|
|
287
|
+
year: "numeric",
|
|
288
|
+
month: "2-digit",
|
|
289
|
+
day: "2-digit",
|
|
290
|
+
hour: "2-digit",
|
|
291
|
+
minute: "2-digit"
|
|
292
|
+
})
|
|
293
|
+
break
|
|
294
|
+
default:
|
|
295
|
+
returnValue = theDate.toLocaleString()
|
|
296
|
+
}
|
|
297
|
+
if (Object.keys(CACHED_DATES[type]).length > 5000) {
|
|
298
|
+
CACHED_DATES[type] = {}
|
|
299
|
+
}
|
|
300
|
+
CACHED_DATES[type][milliseconds] = returnValue
|
|
301
|
+
return returnValue
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Turn string literals into single line, removing spaces at start of line
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
export const noSpaceTmp = (strings, ...values) => {
|
|
309
|
+
const tmpStrings = Array.from(strings)
|
|
310
|
+
|
|
311
|
+
let combined = ""
|
|
312
|
+
while (tmpStrings.length > 0 || values.length > 0) {
|
|
313
|
+
if (tmpStrings.length > 0) {
|
|
314
|
+
combined += tmpStrings.shift()
|
|
315
|
+
}
|
|
316
|
+
if (values.length > 0) {
|
|
317
|
+
combined += values.shift()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let out = ""
|
|
322
|
+
combined.split("\n").forEach(line => {
|
|
323
|
+
out += line.replace(/^\s*/g, "")
|
|
324
|
+
})
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const escapeText = text => {
|
|
329
|
+
return text
|
|
330
|
+
.replace(/&/g, "&")
|
|
331
|
+
.replace(/</g, "<")
|
|
332
|
+
.replace(/>/g, ">")
|
|
333
|
+
.replace(/"/g, """)
|
|
334
|
+
|
|
335
|
+
.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]/g, "") // invalid in XML chars
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Return an inline info-icon with a hover tooltip containing the given HTML.
|
|
340
|
+
* Use only with trusted HTML content.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} html - The tooltip content (HTML string)
|
|
343
|
+
* @returns {string} HTML for the info tooltip
|
|
344
|
+
*/
|
|
345
|
+
export const infoTooltip = html =>
|
|
346
|
+
`<span class="fw-info-tooltip"><i class="fa-solid fa-info-circle"></i><span class="fw-info-tooltip-text">${html}</span></span>`
|
|
347
|
+
|
|
348
|
+
export const unescapeText = text =>
|
|
349
|
+
text
|
|
350
|
+
.replace(/</g, "<")
|
|
351
|
+
.replace(/>/g, ">")
|
|
352
|
+
.replace(/"/g, '"')
|
|
353
|
+
.replace(/&/g, "&")
|
|
354
|
+
/**
|
|
355
|
+
* Return a cancel promise if you need to cancel a promise chain. Import as
|
|
356
|
+
* ES6 promises are not (yet) cancelable.
|
|
357
|
+
*/
|
|
358
|
+
|
|
359
|
+
export const cancelPromise = () => new Promise(() => {})
|
|
360
|
+
|
|
361
|
+
// Check if selector matches one of the ancestors of the event target.
|
|
362
|
+
// Used in switch statements of document event listeners.
|
|
363
|
+
export const findTarget = (event, selector, el = {}) => {
|
|
364
|
+
el.target = event.target.closest(selector)
|
|
365
|
+
if (el.target) {
|
|
366
|
+
event.stopPropagation()
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
return false
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Promise when page has been loaded.
|
|
373
|
+
export const whenReady = () => {
|
|
374
|
+
if (document.readyState === "complete") {
|
|
375
|
+
return Promise.resolve()
|
|
376
|
+
} else {
|
|
377
|
+
return new Promise(resolve => {
|
|
378
|
+
document.addEventListener("readystatechange", _event => {
|
|
379
|
+
if (document.readyState === "complete") {
|
|
380
|
+
resolve()
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const setDocTitle = (title, app) => {
|
|
388
|
+
const titleText = `${title} - ${app.name}`
|
|
389
|
+
if (document.title !== titleText) {
|
|
390
|
+
document.title = titleText
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const LANGUAGES = {
|
|
395
|
+
ar: "العربية",
|
|
396
|
+
bg: "Български",
|
|
397
|
+
cs: "Čeština",
|
|
398
|
+
da: "Dansk",
|
|
399
|
+
de: "Deutsch",
|
|
400
|
+
en: "English",
|
|
401
|
+
es: "Español",
|
|
402
|
+
fr: "Français",
|
|
403
|
+
it: "Italiano",
|
|
404
|
+
ja: "日本語",
|
|
405
|
+
ko: "한국어",
|
|
406
|
+
nb: "Norsk bokmål",
|
|
407
|
+
nl: "Nederlands",
|
|
408
|
+
pl: "Polski",
|
|
409
|
+
"pt-br": "Português (Brasil)",
|
|
410
|
+
"pt-pt": "Português (Portugal)",
|
|
411
|
+
ru: "Русский",
|
|
412
|
+
sv: "Svenska",
|
|
413
|
+
tr: "Türkçe",
|
|
414
|
+
"zh-hans": "简体中文"
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export const langName = code => {
|
|
418
|
+
return LANGUAGES[code] || code
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Enable ISO date picker on a text input by overlaying a native date picker.
|
|
422
|
+
* The text input displays ISO format (YYYY-MM-DD) while using the native picker for selection.
|
|
423
|
+
* @param {HTMLInputElement} inputEl - The text input element to enhance
|
|
424
|
+
* @param {boolean} minToday - If true, sets min date to today (default: false)
|
|
425
|
+
*/
|
|
426
|
+
export const enableDatePicker = (inputEl, minToday = false) => {
|
|
427
|
+
const datePicker = document.createElement("input")
|
|
428
|
+
datePicker.type = "date"
|
|
429
|
+
if (minToday) {
|
|
430
|
+
datePicker.min = new Date().toISOString().split("T")[0]
|
|
431
|
+
}
|
|
432
|
+
datePicker.style.position = "absolute"
|
|
433
|
+
datePicker.style.opacity = "0"
|
|
434
|
+
datePicker.style.pointerEvents = "none"
|
|
435
|
+
|
|
436
|
+
const parent = inputEl.parentElement
|
|
437
|
+
parent.style.position = "relative"
|
|
438
|
+
parent.appendChild(datePicker)
|
|
439
|
+
|
|
440
|
+
inputEl.addEventListener("click", () => datePicker.showPicker())
|
|
441
|
+
datePicker.addEventListener("change", () => {
|
|
442
|
+
inputEl.value = datePicker.value
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// Validate and normalize date on blur or form submission
|
|
446
|
+
const validateDate = () => {
|
|
447
|
+
const value = inputEl.value
|
|
448
|
+
if (!value) {
|
|
449
|
+
return // Empty is valid (optional field)
|
|
450
|
+
}
|
|
451
|
+
const date = new Date(value)
|
|
452
|
+
if (isNaN(date.getTime())) {
|
|
453
|
+
inputEl.value = "" // Invalid date, clear it
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
// Re-format to ensure consistent YYYY-MM-DD format
|
|
457
|
+
const yyyy = date.getFullYear()
|
|
458
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0")
|
|
459
|
+
const dd = String(date.getDate()).padStart(2, "0")
|
|
460
|
+
inputEl.value = `${yyyy}-${mm}-${dd}`
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
inputEl.addEventListener("blur", validateDate)
|
|
464
|
+
inputEl.addEventListener("keydown", event => {
|
|
465
|
+
// Intercept Enter to validate before form submission
|
|
466
|
+
if (event.key === "Enter") {
|
|
467
|
+
validateDate()
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
// Allow typing when date picker is open (it takes focus)
|
|
471
|
+
if (document.activeElement === datePicker) {
|
|
472
|
+
return // Let datePicker handle all keys
|
|
473
|
+
}
|
|
474
|
+
const key = event.key
|
|
475
|
+
// Open picker on Enter
|
|
476
|
+
if (key === "Enter") {
|
|
477
|
+
event.preventDefault()
|
|
478
|
+
datePicker.showPicker()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
// Allow editing: digits, dashes, backspace, delete, arrow keys
|
|
482
|
+
if (
|
|
483
|
+
/^\d$/.test(key) ||
|
|
484
|
+
key === "-" ||
|
|
485
|
+
key === "Backspace" ||
|
|
486
|
+
key === "Delete" ||
|
|
487
|
+
key === "ArrowLeft" ||
|
|
488
|
+
key === "ArrowRight" ||
|
|
489
|
+
key === "Tab"
|
|
490
|
+
) {
|
|
491
|
+
return // Allow default behavior
|
|
492
|
+
}
|
|
493
|
+
// Block other keys
|
|
494
|
+
event.preventDefault()
|
|
495
|
+
})
|
|
496
|
+
// Allow typing while date picker is open
|
|
497
|
+
datePicker.addEventListener("keydown", event => {
|
|
498
|
+
event.stopPropagation()
|
|
499
|
+
})
|
|
500
|
+
}
|
package/src/blob.js
CHANGED