semantic-forms 5.2.1 → 5.3.0

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 CHANGED
@@ -8,7 +8,7 @@
8
8
  "url": "https://github.com/rooseveltframework/semantic-forms/graphs/contributors"
9
9
  }
10
10
  ],
11
- "version": "5.2.1",
11
+ "version": "5.3.0",
12
12
  "files": [
13
13
  "dist",
14
14
  "semanticForms.scss",
@@ -22,12 +22,12 @@
22
22
  "devDependencies": {
23
23
  "@jsdevtools/coverage-istanbul-loader": "3.0.5",
24
24
  "@playwright/test": "1.56.1",
25
- "eslint": "9.38.0",
25
+ "eslint": "9.39.1",
26
26
  "eslint-plugin-html": "8.1.3",
27
27
  "express": "5.1.0",
28
- "nodemon": "3.1.10",
28
+ "nodemon": "3.1.11",
29
29
  "nyc": "17.1.0",
30
- "sass": "1.93.2",
30
+ "sass": "1.94.0",
31
31
  "http-server": "14.1.1",
32
32
  "standard": "17.1.2",
33
33
  "webpack-cli": "6.0.1"
package/semanticForms.js CHANGED
@@ -1,3 +1,6 @@
1
+ const { createKeyboardShortcut, shortcutListener } = require('./lib/keyboardShortcuts.js')
2
+ const { enhanceInput, handleUndoRedo } = require('./lib/inputEnhancements.js')
3
+
1
4
  const semanticForms = () => {
2
5
  // do some feature detection so none of the JS executes if the browser is too old
3
6
  if (typeof document.getElementsByClassName !== 'function' || typeof document.querySelector !== 'function' || !document.body.classList || !window.MutationObserver) {
@@ -5,87 +8,9 @@ const semanticForms = () => {
5
8
  return
6
9
  }
7
10
 
8
- const passwordShow = '<svg fill="none" height="256" viewBox="0 0 24 24" width="256" xmlns="http://www.w3.org/2000/svg"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m1 12s4-8 11-8 11 8 11 8"/><path d="m1 12s4 8 11 8 11-8 11-8"/><circle cx="12" cy="12" r="3"/></g></svg>'
9
- const passwordHide = '<svg fill="none" height="256" viewBox="0 0 24 24" width="256" xmlns="http://www.w3.org/2000/svg"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m2 2 20 20"/><path d="m6.71277 6.7226c-3.04798 2.07267-4.71277 5.2774-4.71277 5.2774s3.63636 7 10 7c2.0503 0 3.8174-.7266 5.2711-1.7116m-6.2711-12.23018c.3254-.03809.6588-.05822 1-.05822 6.3636 0 10 7 10 7s-.6918 1.3317-2 2.8335"/><path d="m14 14.2362c-.5308.475-1.2316.7639-2 .7639-1.6569 0-3-1.3431-3-3 0-.8237.33193-1.5698.86932-2.11192"/></g></svg>'
10
- const helpTextIcon = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" version="1.1" style="overflow:visible;"><rect width="16" height="16" fill="none"/><path fill="currentColor" d="M8,10.5c-0.552,0-1,0.448-1,1c0,0.552,0.448,1,1,1c0.552,0,1-0.448,1-1C9,10.948,8.552,10.5,8,10.5z M8,0 C3.582,0,0,3.582,0,8c0,4.418,3.582,8,8,8s8-3.582,8-8C16,3.582,12.418,0,8,0z M12.243,12.243C11.109,13.376,9.603,14,8,14 s-3.109-0.624-4.243-1.757C2.624,11.109,2,9.603,2,8s0.624-3.109,1.757-4.243C4.891,2.624,6.397,2,8,2s3.109,0.624,4.243,1.757 C13.376,4.891,14,6.397,14,8C14,9.603,13.376,11.109,12.243,12.243z M7.927,3.755c-2.248,0-2.76,1.695-2.802,2.906h1.571 c0.028-0.256,0.101-1.418,1.221-1.418c0.672,0,1.206,0.406,1.206,1.175c0,0.695-0.724,1.155-1.303,1.711 C7.241,8.685,7.168,9.325,7.168,9.824V10c0.146,0,1.615,0,1.615,0V9.824c0-1.256,1.967-1.594,1.967-3.541 C10.75,5.059,9.874,3.755,7.927,3.755z"/></svg>'
11
-
12
- const nodeNameLookup = ['TEXTAREA', 'SELECT']
13
- const inputTypeLookup = ['checkbox', 'color', 'date', 'datetime-local', 'email', 'file', 'image', 'month', 'number', 'password', 'radio', 'range', 'search', 'tel', 'text', 'time', 'url', 'week']
14
- const keyboardShortcuts = []
15
-
16
11
  // custom keyboard shortcut listener
17
- document.addEventListener('keydown', (e) => {
18
- const specialCharMap = {
19
- Minus: '-',
20
- Equal: '=',
21
- BracketLeft: '[',
22
- BracketRight: ']',
23
- Backslash: '\\',
24
- Semicolon: ';',
25
- Quote: '\'',
26
- Comma: ',',
27
- Period: '.',
28
- Slash: '/',
29
- Backquote: '`'
30
- }
31
-
32
- const shiftSpecialCharMap = {
33
- 1: '!',
34
- 2: '@',
35
- 3: '#',
36
- 4: '$',
37
- 5: '%',
38
- 6: '^',
39
- 7: '&',
40
- 8: '*',
41
- 9: '(',
42
- 0: ')',
43
- Minus: '_',
44
- Equal: '+',
45
- BracketLeft: '{',
46
- BracketRight: '}',
47
- Backslash: '|',
48
- Semicolon: ':',
49
- Quote: '"',
50
- Comma: '<',
51
- Period: '>',
52
- Slash: '?',
53
- Backquote: '~'
54
- }
55
-
56
- const shortcut = keyboardShortcuts.find(shortcut => {
57
- let matchesKey = false
58
- if (e.altKey && !e.shiftKey) {
59
- // mac adjusts the key value if altKey is pressed
60
- matchesKey = 'Key' + shortcut.key.toUpperCase() === e.code ||
61
- 'Digit' + shortcut.key.toUpperCase() === e.code ||
62
- shortcut.key === e.key ||
63
- specialCharMap[e.code] === shortcut.key
64
- } else if (e.shiftKey) {
65
- // check shift special character map
66
- const code = e.code.replace(/Key|Digit/, '')
67
- matchesKey = (shiftSpecialCharMap[code] || shiftSpecialCharMap[e.key]) &&
68
- (shiftSpecialCharMap[code] === shortcut.key || shiftSpecialCharMap[e.key] === shortcut.key)
69
- } else {
70
- matchesKey = shortcut.key.toUpperCase() === e.key.toUpperCase()
71
- }
72
- if (!matchesKey) return false
73
-
74
- let matchesModifier
75
- if (shortcut.modifier) {
76
- if (shortcut.modifier === shortcut.defaultModifier) matchesModifier = shortcut.os === 'windows' || shortcut.os === 'linux' ? e.ctrlKey : e.metaKey
77
- if (shortcut.modifier === 'meta') matchesModifier = e.metaKey
78
- if (shortcut.modifier === 'alt') matchesModifier = e.altKey
79
- if (shortcut.modifier === 'ctrl') matchesModifier = e.ctrlKey
80
- }
81
- return matchesModifier
82
- })
83
-
84
- if (shortcut) {
85
- e.preventDefault()
86
- shortcut.input.focus()
87
- }
88
- })
12
+ const keyboardShortcuts = []
13
+ document.addEventListener('keydown', (e) => shortcutListener(e, keyboardShortcuts))
89
14
 
90
15
  // progressively enhance form elements that have the semanticForms class
91
16
  const forms = document.querySelectorAll('form.semanticForms:not(.semanticFormsActive), table.semanticForms:not(.semanticFormsActive)')
@@ -96,498 +21,20 @@ const semanticForms = () => {
96
21
 
97
22
  // update each input in the semantic form
98
23
  const inputs = Array.from(form.querySelectorAll('input, textarea, select'))
99
- for (let input of inputs) {
100
- // ignore input if it has previously been formatted
101
- if (input.classList.contains('semanticform') || !input.id) continue
102
-
103
- const type = input.getAttribute('type')
104
- if (nodeNameLookup.includes(input.nodeName) || inputTypeLookup.includes(type)) {
105
- // recursively find <dl> element
106
- let dl = input.parentNode
107
- while (dl && dl.nodeName !== 'DL') dl = dl.parentNode
108
-
109
- if (!dl) continue
110
- if (!dl.classList.contains('floatLabelForm')) dl.classList.add('floatLabelForm')
111
-
112
- const label = input.parentNode.parentNode.id && (type === 'checkbox' || type === 'radio')
113
- ? document.querySelector('label[data-for=' + input.parentNode.parentNode.id.replace(/\./g, '\\.') + ']')
114
- : document.querySelector('label[for=' + input.id.replace(/\./g, '\\.') + ']')
115
-
116
- if (!label) {
117
- console.error(`semantic-forms: Found an input (${input.id || input.getAttribute('name')}) without a properly associated label. Make sure there is a label for it and the label has a matching "for" attribute.`)
118
- }
119
-
120
- input.classList.add('semanticform')
121
-
122
- // #region create labels
123
-
124
- const newLabel = document.createElement('label')
125
- newLabel.className = 'floatLabelFormAnimatedLabel'
126
-
127
- if (type === 'checkbox' || type === 'radio') {
128
- // recursively find <dd> element
129
- let dd = input.parentNode
130
- while (dd && dd.nodeName !== 'DD') dd = dd.parentNode
131
-
132
- if (!dd) {
133
- console.error(`semantic-forms: Found an input (${input.id || input.getAttribute('name')}) that is not inside a <dd> element.`)
134
- continue
135
- }
136
-
137
- if (dd.firstChild.nodeName !== 'LABEL') {
138
- if (type === 'checkbox' && input.parentNode.nodeName === 'DD') {
139
- newLabel.setAttribute('for', input.id)
140
- input.parentNode.classList.add('singleCheckbox')
141
- newLabel.className = ''
142
- label.setAttribute('hidden', 'hidden')
143
- insertAfter(newLabel, input)
144
- }
145
-
146
- if (type === 'radio' && input.parentNode.nodeName === 'DD') {
147
- newLabel.setAttribute('for', input.id)
148
- input.parentNode.classList.add('singleRadio')
149
- newLabel.className = ''
150
- label.setAttribute('hidden', 'hidden')
151
- insertAfter(newLabel, input)
152
- }
24
+ for (const input of inputs) {
25
+ enhanceInput(input, form)
153
26
 
154
- newLabel.innerHTML = label.innerHTML
155
-
156
- if (label.hasAttribute('title') && label.getAttribute('data-show-help-icon') !== null && !label.querySelector('span.help')) {
157
- const text = label.getAttribute('title')
158
- label.innerHTML += ` <span title="${text}" class="help">${helpTextIcon}</span>`
159
- newLabel.innerHTML += ` <span title="${text}" class="help">${helpTextIcon}</span>`
160
- }
161
-
162
- if (dd.querySelector(':required') && label.getAttribute('data-no-asterisk') === null && !label.querySelector('span.required')) {
163
- const text = label.getAttribute('data-asterisk-text') || 'This field is required.'
164
- label.innerHTML += ` <span title="${text}" class="required">*</span>`
165
- newLabel.innerHTML += ` <span title="${text}" class="required">*</span>`
166
- }
167
-
168
- if (!dd.querySelector('label')) dd.append(newLabel)
169
- }
170
-
171
- // removes old div that a radio or checkbox may have been added to
172
- if (dd.parentElement.nodeName === 'DIV') dd.parentElement.remove()
173
-
174
- const div = document.createElement('div')
175
- div.append(label.closest('dt'), dd)
176
- dl.append(div)
177
- } else {
178
- newLabel.setAttribute('for', input.id)
179
- newLabel.innerHTML = label.innerHTML
180
-
181
- if (input.hasAttribute('title') && label.getAttribute('data-show-help-icon') !== null && !label.querySelector('span.help')) {
182
- const text = input.getAttribute('title')
183
- newLabel.innerHTML += ` <span title="${text}" class="help">${helpTextIcon}</span>`
184
- }
185
-
186
- if (input.hasAttribute('required') && label.getAttribute('data-no-asterisk') === null && !label.querySelector('span.required')) {
187
- const text = label.getAttribute('data-asterisk-text') || 'This field is required.'
188
- newLabel.innerHTML += ` <span title="${text}" class="required">*</span>`
189
- }
190
-
191
- label.setAttribute('hidden', 'hidden')
192
-
193
- insertAfter(newLabel, input)
194
- }
195
- // #endregion
196
-
197
- // #region keyboard shortcuts
198
- if (input.getAttribute('data-focus-key') !== null) {
199
- function handleKeyboardShortcut () {
200
- const os = getOS()
201
- // this is the custom keyword for meta on linux/mac, ctrl on windows
202
- const defaultModifier = 'metactrl'
203
-
204
- // get focus key value
205
- let focusKey = input.getAttribute('data-focus-key')
206
- if (focusKey.length > 1) {
207
- console.error(`Provided focus key "${focusKey}" is more than one character. Using first character only.`)
208
- focusKey = focusKey.toString()[0]
209
- }
210
-
211
- // get focus modifier value
212
- let modifierSymbol
213
- let modifierKey = defaultModifier
214
- const modifierAttr = {
215
- default: input.getAttribute('data-focus-modifier') || defaultModifier,
216
- linux: input.getAttribute('data-focus-modifier-linux'),
217
- mac: input.getAttribute('data-focus-modifier-mac'),
218
- windows: input.getAttribute('data-focus-modifier-win')
219
- }
220
- if (os && modifierAttr[os]) {
221
- // a specific modifier key has been set by the user
222
- modifierKey = modifierAttr[os]
223
- } else {
224
- modifierKey = modifierAttr.default
225
- }
226
-
227
- // validate passed in modifier
228
- const recognizedModifiers = ['ctrl', 'alt', 'opt', 'meta', 'cmd', defaultModifier]
229
- if (!recognizedModifiers.includes(modifierKey)) {
230
- console.error(`Received an unrecognized modifier, "${modifierKey}," defaulting to "${defaultModifier}."`, input)
231
- modifierKey = defaultModifier
232
- }
233
-
234
- // retrieve modifier symbol
235
- if (['alt', 'opt'].includes(modifierKey)) {
236
- modifierSymbol = os === 'mac' ? '⌥' : '⎇'
237
- } else if (['meta', 'win', 'cmd'].includes(modifierKey) || (modifierKey === defaultModifier && os === 'mac')) {
238
- if (os === 'mac') {
239
- modifierSymbol = '⌘'
240
- } else if (os === 'linux') {
241
- modifierSymbol = '◆'
242
- } else {
243
- modifierSymbol = '⊞'
244
- }
245
- } else if (modifierKey === 'ctrl' || (modifierKey === defaultModifier && (os === 'windows' || os === 'linux'))) {
246
- if (os === 'mac') {
247
- modifierSymbol = '⌃'
248
- } else {
249
- modifierSymbol = 'Ctrl'
250
- }
251
- }
252
-
253
- // add the shortcut to the cached array, if not a duplicate
254
- if (keyboardShortcuts.some(shortcut => shortcut.key === focusKey && shortcut.modifier === modifierKey)) {
255
- console.error(`Duplicate keyboard shortcut "${modifierKey} + ${focusKey}" detected. Only the first input will be focusable using this keyboard shortcut.`, input)
256
- } else {
257
- keyboardShortcuts.push({
258
- key: focusKey,
259
- modifier: modifierKey,
260
- input,
261
- os,
262
- defaultModifier
263
- })
264
- }
265
-
266
- // set the shortcut indicator/title
267
- if (input.nodeName === 'TEXTAREA' || input.type === 'text' || input.type === 'number') {
268
- // create focus indicator for valid inputs
269
- const indicator = document.createElement('span')
270
- indicator.classList.add('focus-key')
271
- indicator.innerHTML = `<kbd>${modifierSymbol} ${focusKey.toUpperCase()}</kbd>`
272
- insertAfter(indicator, newLabel)
273
- } else {
274
- // update the input title
275
- if (input.getAttribute('title')) {
276
- input.setAttribute('title', input.getAttribute('title') + ` (${modifierSymbol} + ${focusKey})`)
277
- } else {
278
- input.setAttribute('title', `Focus with ${modifierSymbol} + ${focusKey}`)
279
- }
280
- }
281
- }
282
-
283
- // placed in a function so that it may exit while still completing other semantic-form enhancements
284
- handleKeyboardShortcut()
285
- }
286
- // #endregion
287
-
288
- // #region standard inputs
289
- // check for auto-grow attribute on textareas
290
- if (input.getAttribute('data-auto-grow') !== null) {
291
- // progressively enhance inputs into textareas
292
- if (input.nodeName === 'INPUT' && input.type === 'text') {
293
- const newInput = document.createElement('textarea')
294
- newInput.id = input.id
295
- newInput.class = input.class
296
- newInput.innerText = input.value
297
- newInput.setAttribute('data-auto-grow', '')
298
- input.replaceWith(newInput)
299
- input = newInput
300
- }
301
-
302
- if (input.nodeName === 'TEXTAREA') {
303
- // when pressing enter while this input is focused, we want to submit
304
- input.addEventListener('keypress', (e) => {
305
- if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
306
- e.preventDefault()
307
- form.requestSubmit()
308
- })
309
- }
310
- }
311
-
312
- if (type !== 'checkbox' && type !== 'radio') {
313
- if (!input.getAttribute('placeholder')) input.setAttribute('placeholder', ' ')
314
-
315
- const div = document.createElement('div')
316
- const dt = label.closest('dt')
317
- const dd = input.closest('dd')
318
-
319
- if (!dt) {
320
- console.error(`semantic-forms: Found an input (${input.id || input.getAttribute('name')}) that does not have a corresponding <dt> element.`)
321
- continue
322
- }
323
- if (!dd) {
324
- console.error(`semantic-forms: Found an input (${input.id || input.getAttribute('name')}) that does not have a corresponding <dd> element.`)
325
- continue
326
- }
327
-
328
- // #region input clear button
329
- if (input.nodeName !== 'SELECT' && type !== 'range') {
330
- const clearBtn = document.createElement('button')
331
- clearBtn.type = 'button'
332
- clearBtn.title = input.getAttribute('data-clear-field-text') || 'Clear field'
333
- clearBtn.ariaLabel = input.getAttribute('data-clear-field-text') || 'Clear field'
334
- clearBtn.tabIndex = -1
335
- clearBtn.innerHTML = '<svg viewBox="0 0 16 16" width="18" height="18"><path d="M 1 1 L 15 15 M 1 15 L 15 1" fill="none" stroke-width="2" stroke="currentColor" />'
336
- clearBtn.classList.add('clear')
337
- clearBtn.id = `semanticFormsClearButton_${input.id}`
338
- clearBtn.addEventListener('click', () => {
339
- input.previousValue = input.value
340
- input.value = ''
341
- input.focus()
342
- lastClearFieldPressed = input.id
343
-
344
- // used for any other updates required by various inputs
345
- input.dispatchEvent(new Event('input', { bubbles: true }))
346
- })
347
- insertAfter(clearBtn, dd.querySelector('label'))
348
- }
349
- input.addEventListener('focus', event => {
350
- if (event.target.nodeName === 'INPUT') lastFocusedInput = event.target
351
- })
352
- // #endregion
353
-
354
- // check for colspan- utility class
355
- if (/colspan-/.test(dd.className)) {
356
- const match = dd.className.match(/colspan-([0-9]|full)/)[0]
357
- dd.classList.remove(match)
358
- div.classList.add(match)
359
- }
360
-
361
- // check for max-content attribute
362
- // this may be removed once fully supported in Firefox and Safari: https://caniuse.com/wf-field-sizing
363
- if (input.getAttribute('data-max-content') !== null) {
364
- if (!('fieldSizing' in document.createElement('input').style)) {
365
- const adjustWidth = () => {
366
- const value = input.value !== '' ? input.value : input.placeholder
367
- const width = value.length * 8 + 40
368
- input.style.width = width + 'px'
369
- input.style.maxWidth = '100%'
370
- div.style.width = width + 'px'
371
- }
372
- adjustWidth()
373
- input.addEventListener('input', adjustWidth)
374
- }
375
- }
376
-
377
- div.append(dt, dd)
378
- dl.append(div)
379
-
380
- // determine visibility of newly created <div>
381
- if (dt.style.display === 'none' && dd.style.display === 'none') div.style.display = 'none'
382
- }
383
- // #endregion
384
-
385
- // handle file input clear btn, cannot be handled with CSS
386
- if (type === 'file') {
387
- const clearBtn = input.parentElement.querySelector('.clear')
388
- input.addEventListener('input', event => {
389
- clearBtn.style.display = event.target.files.length ? 'flex' : 'none'
390
- })
391
- clearBtn.addEventListener('click', () => {
392
- clearBtn.style.display = 'none'
393
- })
394
- }
395
-
396
- // handle range inputs with a class to display the value
397
- if (type === 'range' && input.classList.contains('displayValue')) {
398
- const label = input.parentNode.parentNode.querySelector('dd label')
399
- label.innerHTML += `<span class="seperator">: </span><output>${input.value}</output>`
400
- input.addEventListener('input', event => {
401
- const output = event.target.parentNode.parentNode.querySelector('output')
402
- output.innerHTML = event.target.value
403
- })
404
- }
405
-
406
- // #region show password button
407
- if (type === 'password' && input.getAttribute('data-no-reveal') === null) {
408
- const showBtn = document.createElement('button')
409
- showBtn.type = 'button'
410
- showBtn.title = input.getAttribute('data-show-password-text') || 'Show password'
411
- showBtn.ariaLabel = input.getAttribute('data-show-password-text') || 'Show password'
412
- showBtn.tabIndex = -1
413
- showBtn.innerHTML = passwordShow
414
- showBtn.classList.add('show')
415
- showBtn.id = `semanticFormsShowButton_${input.id}`
416
- const dd = input.closest('dd')
417
- if (!dd) {
418
- console.error(`semantic-forms: Found an input (${input.id || input.getAttribute('name')}) that is not inside a <dd> element.`)
419
- continue
420
- }
421
- showBtn.addEventListener('click', () => {
422
- if (input.type === 'password') {
423
- showBtn.innerHTML = passwordHide
424
- showBtn.title = input.getAttribute('data-hide-password-text') || 'Hide password'
425
- showBtn.ariaLabel = input.getAttribute('data-hide-password-text') || 'Hide password'
426
- input.type = 'text'
427
- } else {
428
- showBtn.innerHTML = passwordShow
429
- showBtn.title = input.getAttribute('data-show-password-text') || 'Show password'
430
- showBtn.ariaLabel = input.getAttribute('data-show-password-text') || 'Show password'
431
- input.type = 'password'
432
- }
433
- input.focus()
434
- })
435
- insertAfter(showBtn, dd.querySelector('label'))
436
- }
437
- // #endregion
438
-
439
- // add listener to shift clear button when scrollbar present
440
- if (input.nodeName === 'TEXTAREA') {
441
- if (input.getAttribute('data-disable-autosize') === null) {
442
- // add auto-sizing
443
- input.style.setProperty('resize', 'none')
444
- input.style.setProperty('min-height', '0')
445
- input.style.setProperty('max-height', 'none')
446
- input.style.setProperty('height', 'auto')
447
-
448
- const handleInput = () => {
449
- // reset rows attribute to get accurate scrollHeight
450
- let maxRows = input.getAttribute('data-max-rows')
451
-
452
- if (maxRows) {
453
- if (isNaN(maxRows) || Number(maxRows) <= 0) {
454
- console.warn(`An invalid value was passed to the "data-max-rows" attribute. This value will be ignored.\n\nProvided value: ${input.getAttribute('data-max-rows')}`)
455
- maxRows = null
456
- }
457
- }
458
-
459
- const minRows = input.getAttribute('data-max-rows') && Number(input.getAttribute('data-max-rows')) < 5 ? input.getAttribute('data-max-rows') : '5'
460
- input.setAttribute('rows', minRows)
461
-
462
- // get the computed values object reference
463
- const style = window.getComputedStyle(input)
464
-
465
- // force content-box for size accurate line-height calculation, remove scrollbars, lock width (subtract inline padding and inline border widths), and remove inline padding and borders to keep width consistent (for text wrapping accuracy)
466
- const inlinePadding = parseFloat(style['padding-left']) + parseFloat(style['padding-right'])
467
- const inlineBorderWidth = parseFloat(style['border-left-width']) + parseFloat(style['border-right-width'])
468
- input.style.setProperty('overflow', 'hidden', 'important')
469
- input.style.setProperty('width', (parseFloat(style.width) - inlinePadding - inlineBorderWidth) + 'px')
470
- input.style.setProperty('box-sizing', 'content-box')
471
- input.style.setProperty('padding-inline', '0')
472
- input.style.setProperty('border-width', '0')
473
-
474
- // get the base line height, and top / bottom padding
475
- const blockPadding = parseFloat(style['padding-top']) + parseFloat(style['padding-bottom'])
476
- const lineHeight = style['line-height'] === 'normal'
477
- ? parseFloat(style.height) // if line-height is not explicitly set, use the computed height value (ignore padding due to content-box)
478
- : parseFloat(style['line-height']) // otherwise (line-height is explicitly set), use the computed line-height value
479
-
480
- // get the scroll height (rounding to be safe to ensure cross browser consistency)
481
- const scrollHeight = Math.round(input.scrollHeight)
482
-
483
- // undo overflow, width, border-width, box-sizing & inline padding overrides
484
- input.style.removeProperty('width')
485
- input.style.removeProperty('box-sizing')
486
- input.style.removeProperty('padding-inline')
487
- input.style.removeProperty('border-width')
488
- input.style.removeProperty('overflow')
489
-
490
- // subtract blockPadding from scrollHeight and divide that by our lineHeight to get the row count, round to nearest integer as it will always be within ~.1 of the correct whole number
491
- const rows = Math.round((scrollHeight - blockPadding) / lineHeight)
492
-
493
- // set the calculated rows attribute (limited by rowLimit)
494
- if (maxRows) {
495
- input.setAttribute('rows', '' + Math.min(rows, Number(maxRows)))
496
- } else {
497
- input.setAttribute('rows', '' + rows)
498
- }
499
- }
500
-
501
- input.addEventListener('input', handleInput)
502
-
503
- // trigger the event to set the initial rows value
504
- input.dispatchEvent(new Event('input', { bubbles: true }))
505
- }
506
-
507
- // progressively enhance textarea for Firefox and Safari
508
- // this may be removed once fully supported in Firefox and Safari: https://caniuse.com/wf-field-sizing
509
- if (input.getAttribute('data-auto-grow') !== null) {
510
- const adjustHeight = () => {
511
- if (input.value.length) {
512
- input.style.height = input.scrollHeight + 'px'
513
- } else {
514
- input.style.height = window.getComputedStyle(form).getPropertyValue('--semanticFormsInputHeight')
515
- }
516
- }
517
-
518
- // set initial height to semantic-forms CSS variable
519
- input.style.height = window.getComputedStyle(form).getPropertyValue('--semanticFormsInputHeight')
520
- input.addEventListener('input', adjustHeight)
521
- }
522
-
523
- // shifts the clear button to the right if a scrollbar is present
524
- const shiftClearBtn = () => {
525
- const clearBtn = input.parentElement?.querySelector('button.clear')
526
- if (clearBtn) clearBtn.style.marginRight = input.clientHeight < input.scrollHeight ? '15px' : ''
527
- }
528
- input.addEventListener('input', shiftClearBtn)
529
- input.addEventListener('mouseup', shiftClearBtn)
530
- shiftClearBtn()
531
- }
27
+ // handle keyboard shortcuts
28
+ if (input.getAttribute('data-focus-key') !== null) {
29
+ const shortcut = createKeyboardShortcut(input, keyboardShortcuts)
30
+ keyboardShortcuts.push(shortcut)
532
31
  }
533
32
  }
534
33
  }
535
34
 
536
- /**
537
- * Places an element immediately after another element
538
- * @param {Object} newNode element being placed after the reference node
539
- * @param {*} referenceNode element to be used as reference for new node
540
- */
541
- function insertAfter (newNode, referenceNode) {
542
- if (referenceNode.nextSibling) referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling)
543
- else referenceNode.parentNode.appendChild(newNode)
544
- }
545
-
546
- /**
547
- * Uses the navigator to best determine the clients operating system.
548
- * @returns Operating system string (`mac`, `windows`, `linux`)
549
- */
550
- function getOS () {
551
- const userAgent = window.navigator.userAgent
552
- const platform = window.navigator.platform
553
- let os = null
554
-
555
- if (platform.includes('Win')) {
556
- os = 'windows'
557
- } else if (platform.includes('Mac') || /iPhone|iPad|iPod/.test(userAgent)) {
558
- os = 'mac'
559
- } else if (platform.includes('Linux') || /Android/.test(userAgent)) {
560
- os = 'linux'
561
- }
562
- return os
563
- }
564
-
565
- // handle undo/redo
566
- let lastFocusedInput
567
- let lastClearFieldPressed
568
- document.addEventListener('keydown', event => {
569
- if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
570
- // undo clearing a field
571
- if (lastFocusedInput) {
572
- if (lastFocusedInput?.parentNode?.querySelector('button.clear').id === `semanticFormsClearButton_${lastClearFieldPressed}` || lastFocusedInput?.parentNode?.querySelector('button.clear').name === `semanticFormsClearButton_${lastClearFieldPressed}`) {
573
- if (lastFocusedInput.previousValue) {
574
- lastFocusedInput.redoValue = lastFocusedInput.value
575
- lastFocusedInput.value = lastFocusedInput.previousValue
576
- }
577
- }
578
- }
579
- } else if ((event.ctrlKey && event.key === 'y') || (event.metaKey && event.shiftKey && event.key === 'z')) {
580
- // redo clearing a field
581
- if (lastFocusedInput) {
582
- if (lastFocusedInput?.parentNode?.querySelector('button.clear').id === `semanticFormsClearButton_${lastClearFieldPressed}` || lastFocusedInput?.parentNode?.querySelector('button.clear').name === `semanticFormsClearButton_${lastClearFieldPressed}`) {
583
- if (lastFocusedInput.redoValue) {
584
- lastFocusedInput.previousValue = lastFocusedInput.value
585
- lastFocusedInput.value = lastFocusedInput.redoValue
586
- }
587
- }
588
- }
589
- }
590
- })
35
+ // prevents multiple listeners
36
+ document.removeEventListener('keydown', handleUndoRedo)
37
+ document.addEventListener('keydown', handleUndoRedo)
591
38
 
592
39
  // monitor changes to the DOM and enhance new semanticForms forms that get added
593
40
  if (!window.semanticFormsObserver) {