semantic-forms 5.1.11 → 5.2.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.
@@ -106,6 +106,80 @@
106
106
  </dl>
107
107
  </form>
108
108
 
109
+ <h5>Add inline buttons</h5>
110
+
111
+ <p>Include a button or input with the type "submit" in the <code><!--#<dd>--></code> element to place it next to the input:</p>
112
+
113
+ <pre><code class='language-html' lang='html'><escape>
114
+ <form class="semanticForms" method="post">
115
+ <dl>
116
+ <dt><label for="email">Email address</label></dt>
117
+ <dd>
118
+ <input id="email" name="email" type="email" placeholder="Enter an email address" autocomplete="off">
119
+ <button type="submit">Sign up</button>
120
+ </dd>
121
+ </dl>
122
+ </form>
123
+ </escape></code></pre>
124
+
125
+ <form class="semanticForms" method="post">
126
+ <dl>
127
+ <dt><label for="email">Email address</label></dt>
128
+ <dd>
129
+ <input id="email" name="email" type="email" placeholder="Enter an email address" autocomplete="off">
130
+ <button type="submit">Sign up!</button>
131
+ </dd>
132
+ </dl>
133
+ </form>
134
+
135
+ <h5>Add custom keyboard shortcuts</h5>
136
+
137
+ <p>Add keyboard shortcuts on specific inputs to focus them by adding a <code>data-focus-key</code> attribute and optional <code>data-focus-modifier</code> attribute.</p>
138
+
139
+ <pre><code class='language-html' lang='html'><escape>
140
+ <form class="semanticForms" method="post">
141
+ <dl>
142
+ <dt><label for="focus-1">Focus me!</label></dt>
143
+ <dd><input id="focus-1" name="focus-1" type="text" data-focus-key="Y"></dd>
144
+
145
+ <!-- change the modifier -->
146
+ <dt><label for="focus-2">Focus me!</label></dt>
147
+ <dd><input id="focus-2" name="focus-2" type="text" data-focus-key="Y" data-focus-modifier="alt"></dd>
148
+
149
+ <!-- specify modifier by OS -->
150
+ <dt><label for="focus-2">Focus me!</label></dt>
151
+ <dd><input id="focus-2" name="focus-2" type="text" data-focus-key="Z" data-focus-modifier-win="alt" data-focus-modifier-linux="meta" data-focus-modifier-mac="cmd"></dd>
152
+ </dl>
153
+ </form>
154
+ </escape></code></pre>
155
+
156
+ <form class="semanticForms" method="post">
157
+ <dl>
158
+ <dt><label for="focus-1">Focus me!</label></dt>
159
+ <dd><input id="focus-1" name="focus-1" type="text" data-focus-key="Y"></dd>
160
+
161
+ <!-- change the modifier -->
162
+ <dt><label for="focus-2">Focus me!</label></dt>
163
+ <dd><input id="focus-2" name="focus-2" type="text" data-focus-key="Y" data-focus-modifier="alt"></dd>
164
+
165
+ <!-- specify modifier by OS -->
166
+ <dt><label for="focus-3">Focus me!</label></dt>
167
+ <dd><input id="focus-3" name="focus-3" type="text" data-focus-key="Z" data-focus-modifier-win="alt" data-focus-modifier-linux="meta" data-focus-modifier-max="cmd"></dd>
168
+ </dl>
169
+ </form>
170
+
171
+ <ul>
172
+ <li><p>The focus key should be a single character, and is not case-sensitive. If multiple characters are detected, only the first one will be used. Special characters (!, @, #, etc.) will require <kbd>Shift</kbd> to be pressed with the keyboard shortcut.</p></li>
173
+ <li>
174
+ <p>The modifier key value may be "metactrl," "ctrl," "cmd," "meta," or "alt." The default value is "metactrl" and will automatically be applied if the <code>data-focus-modifier</code> attribute is not present.</p>
175
+ <ul>
176
+ <li>Some combinations are unable to be used due to conflicts with browser and OS keyboard shortcuts. See <a href="https://rooseveltframework.org/docs/semantic-forms/latest/reservedKeyboardShortcuts">reserved keyboard shortcuts</a> for a full overview.</li>
177
+ <li><p>OS-specific modifier keys can be set with <code>data-focus-modifier-win</code> (Windows), <code>data-focus-modifier-linux</code> (Linux) and <code>data-focus-modifier-mac</code> (Mac).</p></li>
178
+ <li><p>Due to a limited range of available shortcuts, the "Start" (win) key on Windows will be replaced with "Ctrl."</p></li>
179
+ </ul>
180
+ </li>
181
+ </ul>
182
+
109
183
  <h5 id="custominputfields">Customizing input fields</h5>
110
184
 
111
185
  <p>By default, input fields will display an X icon to clear the text and password fields will also display a show/hide toggle for displaying the password in plain text. You can customize this behavior as follows:</p>
@@ -121,6 +195,13 @@
121
195
  <li>Note that checkboxes and radios should have a <code>title</code> attribute on the label instead of the inputs.</li>
122
196
  </ul>
123
197
  </li>
198
+ <li><p>Allow fixed-width inputs to grow/shrink to their longest content by applying a <code>data-max-content</code> attribute to the input.</p></li>
199
+ <li>
200
+ <p>Create a one-line text input that grows in height by applying a <code>data-auto-grow</code> attribute to a <code>textarea</code> or <code>input</code> (with type "text") element.</p>
201
+ <ul>
202
+ <li>Note that pressing <kbd>Enter</kbd> with this attribute will attempt to submit the form. Use <kbd>Shift + Enter</kbd> to add new lines.</li>
203
+ </ul>
204
+ </li>
124
205
  </ul>
125
206
 
126
207
  <p>You can also disable float labels on a per-field basis with the <code>no-float-label</code> class:</p>
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.1.11",
11
+ "version": "5.2.0",
12
12
  "files": [
13
13
  "dist",
14
14
  "semanticForms.scss",
@@ -21,8 +21,8 @@
21
21
  "readmeFilename": "README.md",
22
22
  "devDependencies": {
23
23
  "@jsdevtools/coverage-istanbul-loader": "3.0.5",
24
- "@playwright/test": "1.56.0",
25
- "eslint": "9.37.0",
24
+ "@playwright/test": "1.56.1",
25
+ "eslint": "9.38.0",
26
26
  "eslint-plugin-html": "8.1.3",
27
27
  "express": "5.1.0",
28
28
  "nodemon": "3.1.10",
package/semanticForms.js CHANGED
@@ -7,13 +7,88 @@ const semanticForms = () => {
7
7
 
8
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
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;margin-top:1px;"><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>'
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
11
 
12
12
  const nodeNameLookup = ['TEXTAREA', 'SELECT']
13
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
+ // 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
+ })
14
89
 
15
90
  // progressively enhance form elements that have the semanticForms class
16
- const forms = document.querySelectorAll('form.semanticForms:not(.semanticFormsActive)')
91
+ const forms = document.querySelectorAll('form.semanticForms:not(.semanticFormsActive), table.semanticForms:not(.semanticFormsActive)')
17
92
 
18
93
  for (const form of forms) {
19
94
  form.classList.add('semanticFormsActive')
@@ -21,7 +96,7 @@ const semanticForms = () => {
21
96
 
22
97
  // update each input in the semantic form
23
98
  const inputs = Array.from(form.querySelectorAll('input, textarea, select'))
24
- for (const input of inputs) {
99
+ for (let input of inputs) {
25
100
  // ignore input if it has previously been formatted
26
101
  if (input.classList.contains('semanticform') || !input.id) continue
27
102
 
@@ -46,6 +121,9 @@ const semanticForms = () => {
46
121
 
47
122
  // #region create labels
48
123
 
124
+ const newLabel = document.createElement('label')
125
+ newLabel.className = 'floatLabelFormAnimatedLabel'
126
+
49
127
  if (type === 'checkbox' || type === 'radio') {
50
128
  // recursively find <dd> element
51
129
  let dd = input.parentNode
@@ -57,9 +135,6 @@ const semanticForms = () => {
57
135
  }
58
136
 
59
137
  if (dd.firstChild.nodeName !== 'LABEL') {
60
- const newLabel = document.createElement('label')
61
- newLabel.className = 'floatLabelFormAnimatedLabel'
62
-
63
138
  if (type === 'checkbox' && input.parentNode.nodeName === 'DD') {
64
139
  newLabel.setAttribute('for', input.id)
65
140
  input.parentNode.classList.add('singleCheckbox')
@@ -100,10 +175,7 @@ const semanticForms = () => {
100
175
  div.append(label.closest('dt'), dd)
101
176
  dl.append(div)
102
177
  } else {
103
- const newLabel = document.createElement('label')
104
178
  newLabel.setAttribute('for', input.id)
105
- newLabel.className = 'floatLabelFormAnimatedLabel'
106
-
107
179
  newLabel.innerHTML = label.innerHTML
108
180
 
109
181
  if (input.hasAttribute('title') && label.getAttribute('data-show-help-icon') !== null && !label.querySelector('span.help')) {
@@ -122,7 +194,121 @@ const semanticForms = () => {
122
194
  }
123
195
  // #endregion
124
196
 
125
- // standard inputs
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
+
126
312
  if (type !== 'checkbox' && type !== 'radio') {
127
313
  if (!input.getAttribute('placeholder')) input.setAttribute('placeholder', ' ')
128
314
 
@@ -139,7 +325,7 @@ const semanticForms = () => {
139
325
  continue
140
326
  }
141
327
 
142
- // #region clear button
328
+ // #region input clear button
143
329
  if (input.nodeName !== 'SELECT' && type !== 'range') {
144
330
  const clearBtn = document.createElement('button')
145
331
  clearBtn.type = 'button'
@@ -172,12 +358,29 @@ const semanticForms = () => {
172
358
  div.classList.add(match)
173
359
  }
174
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
+
175
377
  div.append(dt, dd)
176
378
  dl.append(div)
177
379
 
178
380
  // determine visibility of newly created <div>
179
381
  if (dt.style.display === 'none' && dd.style.display === 'none') div.style.display = 'none'
180
382
  }
383
+ // #endregion
181
384
 
182
385
  // handle file input clear btn, cannot be handled with CSS
183
386
  if (type === 'file') {
@@ -301,6 +504,22 @@ const semanticForms = () => {
301
504
  input.dispatchEvent(new Event('input', { bubbles: true }))
302
505
  }
303
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
+
304
523
  // shifts the clear button to the right if a scrollbar is present
305
524
  const shiftClearBtn = () => {
306
525
  const clearBtn = input.parentElement?.querySelector('button.clear')
@@ -324,6 +543,25 @@ const semanticForms = () => {
324
543
  else referenceNode.parentNode.appendChild(newNode)
325
544
  }
326
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
+
327
565
  // handle undo/redo
328
566
  let lastFocusedInput
329
567
  let lastClearFieldPressed