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.
@@ -0,0 +1,611 @@
1
+ import {DiffDOM} from "diff-dom"
2
+ import {keyName} from "w3c-keyname"
3
+ import {escapeText, whenReady} from "./basic"
4
+
5
+ export class OverviewMenuView {
6
+ constructor(overview, model) {
7
+ this.overview = overview
8
+ this.model = model()
9
+ this.dd = new DiffDOM({
10
+ valueDiffing: false
11
+ })
12
+ this.openedMenu = false
13
+ this.listeners = {}
14
+ this.keyboardShortcuts = new Map()
15
+ }
16
+
17
+ init() {
18
+ whenReady().then(() => {
19
+ this.addMissingIds(this.model)
20
+ this.bindEvents()
21
+ this.setupKeyboardShortcuts()
22
+ })
23
+ }
24
+
25
+ addMissingIds(menu) {
26
+ // Add missing ids to menu items that don't have an ID.
27
+ menu.content.forEach(item => {
28
+ if (!item.id) {
29
+ item.id = Math.random().toString(36).substring(2)
30
+ }
31
+ if (item.type === "dropdown") {
32
+ this.addMissingIds(item)
33
+ }
34
+ })
35
+ }
36
+
37
+ bindEvents() {
38
+ this.menuEl = document.getElementById("fw-overview-menu")
39
+ this.listeners.onclick = event => this.onclick(event)
40
+ document.body.addEventListener("click", this.listeners.onclick)
41
+ this.listeners.oninput = event => this.oninput(event)
42
+ document.body.addEventListener("input", this.listeners.oninput)
43
+ this.listeners.onKeydown = event => this.onKeydown(event)
44
+ document.body.addEventListener("keydown", this.listeners.onKeydown)
45
+ this.listeners.onFocus = event => this.onFocus(event)
46
+ document.body.addEventListener("focus", this.listeners.onFocus, true)
47
+ this.update()
48
+ }
49
+
50
+ setupKeyboardShortcuts() {
51
+ // Map all keyboard shortcuts from the menu model
52
+ this.model.content.forEach(menuItem => {
53
+ if (menuItem.keys) {
54
+ this.keyboardShortcuts.set(
55
+ menuItem.keys.toLowerCase(),
56
+ menuItem
57
+ )
58
+ }
59
+ })
60
+ }
61
+
62
+ onKeydown(event) {
63
+ let name = keyName(event)
64
+ if (event.altKey) {
65
+ name = "alt-" + name.toLowerCase()
66
+ const menuItem = this.keyboardShortcuts.get(name)
67
+ if (menuItem) {
68
+ event.preventDefault()
69
+ event.stopPropagation()
70
+ event.stopImmediatePropagation()
71
+
72
+ if (menuItem.type === "search") {
73
+ const inputEl = document.getElementById(
74
+ `${menuItem.id}-input`
75
+ )
76
+ if (inputEl) {
77
+ inputEl.focus()
78
+ }
79
+ } else if (menuItem.type === "dropdown") {
80
+ // Toggle dropdown menu
81
+ // If the menu is already open, close it
82
+ if (
83
+ this.openedMenu === this.model.content.indexOf(menuItem)
84
+ ) {
85
+ menuItem.open = false
86
+ this.openedMenu = false
87
+ this.update()
88
+ } else {
89
+ if (this.openedMenu !== false) {
90
+ this.model.content[this.openedMenu].open = false
91
+ }
92
+ menuItem.open = true
93
+ this.openedMenu = this.model.content.indexOf(menuItem)
94
+ this.update()
95
+ const firstDropdownItem = this.menuEl.querySelector(
96
+ `.fw-pulldown-item.selected`
97
+ )
98
+ if (firstDropdownItem) {
99
+ firstDropdownItem.focus()
100
+ }
101
+ }
102
+ } else if (menuItem.action) {
103
+ menuItem.action(this.overview)
104
+ }
105
+ return
106
+ }
107
+ }
108
+
109
+ // Handle horizontal navigation between menu items
110
+ if (name === "ArrowLeft" || name === "ArrowRight") {
111
+ const menuItems = Array.from(
112
+ this.menuEl.querySelectorAll("#fw-overview-menu > li")
113
+ )
114
+ const focusedElement = document.activeElement
115
+ const currentMenuItem = focusedElement.closest("li")
116
+
117
+ if (currentMenuItem) {
118
+ event.preventDefault()
119
+ const currentIndex = menuItems.indexOf(currentMenuItem)
120
+ let newIndex
121
+
122
+ if (name === "ArrowLeft") {
123
+ newIndex =
124
+ currentIndex > 0
125
+ ? currentIndex - 1
126
+ : menuItems.length - 1
127
+ } else {
128
+ newIndex =
129
+ currentIndex < menuItems.length - 1
130
+ ? currentIndex + 1
131
+ : 0
132
+ }
133
+
134
+ const nextMenuItem = menuItems[newIndex].querySelector(
135
+ ".fw-dropdown-menu, .fw-text-menu, button, input"
136
+ )
137
+ if (nextMenuItem) {
138
+ nextMenuItem.focus()
139
+ }
140
+ }
141
+ return
142
+ }
143
+
144
+ // Handle Enter and Space to open dropdown menus
145
+ if (name === "Enter" || name === " ") {
146
+ const focusedElement = document.activeElement
147
+ if (focusedElement.matches(".fw-dropdown-menu")) {
148
+ event.preventDefault()
149
+ const menuItem = this.findMenuItemFromElement(focusedElement)
150
+ if (menuItem && menuItem.type === "dropdown") {
151
+ if (this.openedMenu !== false) {
152
+ this.model.content[this.openedMenu].open = false
153
+ }
154
+ menuItem.open = true
155
+ this.openedMenu = this.model.content.indexOf(menuItem)
156
+ menuItem.selectedIndex = 0
157
+ this.update()
158
+
159
+ const firstDropdownItem = this.menuEl.querySelector(
160
+ `.fw-pulldown-item.selected`
161
+ )
162
+ if (firstDropdownItem) {
163
+ firstDropdownItem.focus()
164
+ }
165
+ }
166
+ return
167
+ }
168
+ }
169
+
170
+ if (this.openedMenu !== false) {
171
+ const menuItem = this.model.content[this.openedMenu]
172
+
173
+ if (menuItem.type === "dropdown") {
174
+ if (name === "ArrowDown" || name === "ArrowUp") {
175
+ event.preventDefault()
176
+ event.stopPropagation()
177
+
178
+ // Find currently selected item
179
+ let selectedIndex = -1
180
+ if (menuItem.selectedIndex !== undefined) {
181
+ selectedIndex = menuItem.selectedIndex
182
+ }
183
+
184
+ // Calculate new index
185
+ if (name === "ArrowDown") {
186
+ selectedIndex =
187
+ selectedIndex < menuItem.content.length - 1
188
+ ? selectedIndex + 1
189
+ : 0
190
+ } else {
191
+ selectedIndex -= 1
192
+ if (selectedIndex < 0) {
193
+ // Close menu
194
+ menuItem.open = false
195
+ this.openedMenu = false
196
+ delete menuItem.selectedIndex
197
+ this.update()
198
+ const dropdownButton = this.menuEl.querySelector(
199
+ `#${menuItem.id}-button`
200
+ )
201
+ if (dropdownButton) {
202
+ dropdownButton.focus()
203
+ }
204
+ }
205
+ }
206
+
207
+ menuItem.selectedIndex = selectedIndex
208
+ this.update()
209
+ const selectedEl = this.menuEl.querySelector(
210
+ `.fw-pulldown-item.selected`
211
+ )
212
+ if (selectedEl) {
213
+ selectedEl.focus()
214
+ }
215
+ } else if (name === "Enter" || name === " ") {
216
+ event.preventDefault()
217
+ event.stopPropagation()
218
+
219
+ if (
220
+ menuItem.selectedIndex !== undefined &&
221
+ menuItem.content[menuItem.selectedIndex]
222
+ ) {
223
+ const selectedItem =
224
+ menuItem.content[menuItem.selectedIndex]
225
+ if (selectedItem.action) {
226
+ selectedItem.action(this.overview)
227
+ menuItem.open = false
228
+ this.openedMenu = false
229
+ delete menuItem.selectedIndex
230
+ this.update()
231
+ }
232
+ }
233
+ } else if (name === "Escape") {
234
+ event.preventDefault()
235
+ event.stopPropagation()
236
+
237
+ menuItem.open = false
238
+ this.openedMenu = false
239
+ delete menuItem.selectedIndex
240
+ this.update()
241
+ const dropdownButton = document.getElementById(
242
+ `${menuItem.id}-button`
243
+ )
244
+ if (dropdownButton) {
245
+ dropdownButton.focus()
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ onFocus(event) {
253
+ // Ignore if the focus event is triggered by JavaScript
254
+ if (event.isTrusted === false) {
255
+ return
256
+ }
257
+ const target = event.target
258
+ if (this.openedMenu !== false) {
259
+ if (target.matches("#fw-overview-menu li .fw-pulldown-item")) {
260
+ const menuItem = this.model.content[this.openedMenu]
261
+ if (menuItem) {
262
+ const itemNumber = Array.from(
263
+ target.parentElement.parentElement.children
264
+ ).indexOf(target.parentElement)
265
+ menuItem.selectedIndex = itemNumber
266
+ this.update()
267
+ }
268
+ } else {
269
+ // Close dropdown menu if focus is outside of the dropdown
270
+ const menuItem = this.model.content[this.openedMenu]
271
+ if (menuItem) {
272
+ menuItem.open = false
273
+ delete menuItem.selectedIndex
274
+ this.openedMenu = false
275
+ this.update()
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ findMenuItemFromElement(element) {
282
+ const menuItem = element.closest("li")
283
+ if (!menuItem) {
284
+ return null
285
+ }
286
+
287
+ let menuNumber = 0
288
+ let seekItem = menuItem
289
+ while (seekItem.previousElementSibling) {
290
+ menuNumber++
291
+ seekItem = seekItem.previousElementSibling
292
+ }
293
+ return this.model.content[menuNumber]
294
+ }
295
+
296
+ focusMenuItem(menuItem) {
297
+ const menuEl = this.menuEl.querySelector(`#${menuItem.id}`)
298
+ if (menuEl) {
299
+ menuEl.focus()
300
+ }
301
+ }
302
+
303
+ oninput(event) {
304
+ const target = event.target
305
+ if (target.matches("#fw-overview-menu > li > .fw-button > input")) {
306
+ // A text was entered in a top entry. we find which one.
307
+ let menuNumber = 0
308
+ let seekItem = target.closest("li")
309
+ while (seekItem.previousElementSibling) {
310
+ menuNumber++
311
+ seekItem = seekItem.previousElementSibling
312
+ }
313
+ const menuItem = this.model.content[menuNumber]
314
+ if (menuItem.input) {
315
+ menuItem.input(this.overview, target.value)
316
+ target.focus()
317
+ }
318
+ }
319
+ }
320
+
321
+ onclick(event) {
322
+ const target = event.target
323
+ if (
324
+ target.matches("#fw-overview-menu li li, #fw-overview-menu li li *")
325
+ ) {
326
+ event.preventDefault()
327
+ let itemNumber = 0
328
+ let seekItem = target.closest("li")
329
+ while (seekItem.previousElementSibling) {
330
+ itemNumber++
331
+ seekItem = seekItem.previousElementSibling
332
+ }
333
+ let menuNumber = 0
334
+ seekItem = seekItem.parentElement.parentElement.parentElement
335
+ while (seekItem.previousElementSibling) {
336
+ menuNumber++
337
+ seekItem = seekItem.previousElementSibling
338
+ }
339
+ this.model.content[menuNumber].content[itemNumber].action(
340
+ this.overview
341
+ )
342
+ this.model.content[menuNumber].open = false
343
+
344
+ if (this.model.content[menuNumber].type === "dropdown") {
345
+ this.model.content[menuNumber].title =
346
+ this.model.content[menuNumber].content[itemNumber].title
347
+ this.openedMenu = false
348
+ this.update()
349
+ }
350
+ return false
351
+ } else if (
352
+ target.matches(
353
+ "#fw-overview-menu li .select-action input[type=checkbox]"
354
+ )
355
+ ) {
356
+ event.preventDefault()
357
+ event.stopImmediatePropagation()
358
+ event.stopPropagation()
359
+ // A toolbar dropdown menu item was clicked. We just need to
360
+ // find out which one
361
+ let menuNumber = 0
362
+ let seekItem = target.closest("li")
363
+ while (seekItem.previousElementSibling) {
364
+ menuNumber++
365
+ seekItem = seekItem.previousElementSibling
366
+ }
367
+ const menuItem = this.model.content[menuNumber]
368
+
369
+ if (menuItem.checked === true) {
370
+ menuItem.checked = false
371
+ menuItem.uncheckAction(this.overview)
372
+ } else {
373
+ menuItem.checked = true
374
+ menuItem.checkAction(this.overview)
375
+ }
376
+ return true
377
+ } else if (
378
+ target.matches("#fw-overview-menu li, #fw-overview-menu li *")
379
+ ) {
380
+ // A toolbar dropdown menu item was clicked. We just need to
381
+ // find out which one
382
+ let menuNumber = 0
383
+ let seekItem = target.closest("li")
384
+ while (seekItem.previousElementSibling) {
385
+ menuNumber++
386
+ seekItem = seekItem.previousElementSibling
387
+ }
388
+ const menuItem = this.model.content[menuNumber]
389
+ // if it is a dropdown menu, open it. Otherwise execute an
390
+ // associated action.
391
+ if (
392
+ ["dropdown", "select-action-dropdown"].includes(menuItem.type)
393
+ ) {
394
+ event.preventDefault()
395
+ if (this.openedMenu === menuNumber) {
396
+ this.model.content[this.openedMenu].open = false
397
+ this.openedMenu = false
398
+ } else {
399
+ if (this.openedMenu !== false) {
400
+ this.model.content[this.openedMenu].open = false
401
+ }
402
+ menuItem.open = true
403
+ this.openedMenu = menuNumber
404
+ }
405
+ this.update()
406
+ } else if (menuItem.action) {
407
+ event.preventDefault()
408
+ menuItem.action(this.overview)
409
+ this.announceForScreenReader(gettext("Action completed"))
410
+ if (this.openedMenu !== false) {
411
+ this.model.content[this.openedMenu].open = false
412
+ this.openedMenu = false
413
+ }
414
+ this.update()
415
+ }
416
+ return false
417
+ } else if (this.openedMenu !== false) {
418
+ this.model.content[this.openedMenu].open = false
419
+ this.openedMenu = false
420
+ this.update()
421
+ }
422
+ }
423
+
424
+ update() {
425
+ if (!this.menuEl) {
426
+ // page has not yet been loaded. abort
427
+ return
428
+ }
429
+ const diff = this.dd.diff(this.menuEl, this.getMenuHTML())
430
+ this.dd.apply(this.menuEl, diff)
431
+ }
432
+
433
+ getMenuHTML() {
434
+ return `<ul id="fw-overview-menu">${this.model.content
435
+ .map(
436
+ menuItem =>
437
+ `<li class="fw-overview-menu-item${menuItem.id ? ` ${menuItem.id}` : ""} ${menuItem.type}">${this.getMenuItemHTML(
438
+ menuItem
439
+ )}</li>`
440
+ )
441
+ .join("")}</ul>`
442
+ }
443
+
444
+ // Underline access keys
445
+ getAccessKeyHTML(title, accessKey) {
446
+ if (!accessKey) {
447
+ return escapeText(title)
448
+ }
449
+ const index = title.toLowerCase().indexOf(accessKey.toLowerCase())
450
+ if (index === -1) {
451
+ return escapeText(title)
452
+ }
453
+ return `${escapeText(title.substring(0, index))}<span class="access-key">${escapeText(
454
+ title.charAt(index)
455
+ )}</span>${escapeText(title.substring(index + 1))}`
456
+ }
457
+
458
+ getMenuItemHTML(menuItem) {
459
+ let returnValue
460
+ switch (menuItem.type) {
461
+ case "dropdown":
462
+ returnValue = this.getDropdownHTML(menuItem)
463
+ break
464
+ case "select-action-dropdown":
465
+ returnValue = this.getSelectionActionDropdownHTML(menuItem)
466
+ break
467
+ case "text":
468
+ returnValue = this.getTextHTML(menuItem)
469
+ break
470
+ case "button":
471
+ returnValue = this.getButtonHTML(menuItem)
472
+ break
473
+ case "search":
474
+ returnValue = this.getSearchHTML(menuItem)
475
+ break
476
+ default:
477
+ returnValue = ""
478
+ break
479
+ }
480
+ return returnValue
481
+ }
482
+
483
+ getSelectionActionDropdownHTML(menuItem) {
484
+ return `
485
+ <div class="select-action fw-button fw-light fw-large">
486
+ <input type="checkbox" ${menuItem.checked ? "checked" : ""}>
487
+ <span class="select-action-dropdown"><i class="fa-solid fa-caret-down"></i></span>
488
+ </div>
489
+ ${this.getDropdownListHTML(menuItem)}
490
+ `
491
+ }
492
+
493
+ getDropdownHTML(menuItem) {
494
+ const accessKey = menuItem.keys?.split("-")[1]
495
+ return `
496
+ <div class="dropdown fw-dropdown-menu"
497
+ role="button"
498
+ aria-haspopup="true"
499
+ aria-expanded="${menuItem.open ? "true" : "false"}"
500
+ tabindex="0"
501
+ id="${menuItem.id}-button">
502
+ <label id="${menuItem.id}-label">
503
+ ${this.getAccessKeyHTML(
504
+ menuItem.title
505
+ ? menuItem.title
506
+ : menuItem.content.length
507
+ ? menuItem.content[0].title
508
+ : "",
509
+ accessKey
510
+ )}
511
+ </label>
512
+ <span class="dropdown" aria-hidden="true">
513
+ <i class="fa-solid fa-caret-down"></i>
514
+ </span>
515
+ </div>
516
+ ${this.getDropdownListHTML(menuItem)}
517
+ `
518
+ }
519
+
520
+ getDropdownListHTML(menuItem) {
521
+ if (menuItem.open) {
522
+ return `<div class="fw-pulldown fw-left"
523
+ role="menu"
524
+ style="display: block;"
525
+ aria-labelledby="${menuItem.id}-button"
526
+ >
527
+ <ul role="presentation">${menuItem.content
528
+ .map((menuOption, index) =>
529
+ this.getDropdownOptionHTML(menuOption, index)
530
+ )
531
+ .join("")}</ul></div>`
532
+ } else {
533
+ return ""
534
+ }
535
+ }
536
+
537
+ getDropdownOptionHTML(menuOption, index) {
538
+ const menuItem = this.model.content[this.openedMenu]
539
+ const isSelected = menuItem.selectedIndex === index
540
+ return `
541
+ <li role="none">
542
+ <span class="fw-pulldown-item${isSelected ? " selected" : ""}"
543
+ role="menuitem" tabindex="0">
544
+ ${escapeText(menuOption.title)}
545
+ </span>
546
+ </li>
547
+ `
548
+ }
549
+
550
+ getButtonHTML(menuItem) {
551
+ return `
552
+ <button class="fw-button fw-light fw-large"
553
+ title="${menuItem.title}"
554
+ tabindex="0"
555
+ role="menuitem">
556
+ ${menuItem.title}
557
+ ${menuItem.icon ? `<i class="fa-solid fa-${menuItem.icon}" aria-hidden="true"></i>` : ""}
558
+ </button>`
559
+ }
560
+
561
+ announceForScreenReader(message) {
562
+ const announcement = document.createElement("div")
563
+ announcement.setAttribute("aria-live", "polite")
564
+ announcement.classList.add("sr-only") // CSS to visually hide but keep available to screen readers
565
+ announcement.textContent = message
566
+ document.body.appendChild(announcement)
567
+ setTimeout(() => announcement.remove(), 1000)
568
+ }
569
+
570
+ getTextHTML(menuItem) {
571
+ const accessKey = menuItem.keys?.split("-")[1]
572
+ return `
573
+ <button class="fw-text-menu"
574
+ title="${menuItem.title}${menuItem.keys ? ` (${menuItem.keys})` : ""}"
575
+ >
576
+ ${this.getAccessKeyHTML(menuItem.title, accessKey)}
577
+ </button>`
578
+ }
579
+
580
+ getSearchHTML(menuItem) {
581
+ const accessKey = menuItem.keys?.split("-")[1]
582
+ return `
583
+ <div class="fw-button fw-light fw-large disabled fw-search-field-container">
584
+ <label for="${menuItem.id}-input" class="fw-search-label">
585
+ ${this.getAccessKeyHTML(menuItem.title, accessKey)}
586
+ </label>
587
+ <input type="search"
588
+ class="fw-search-field"
589
+ id="${menuItem.id}-input"
590
+ aria-description="${gettext("Type to search")}"
591
+ placeholder="${menuItem.title}"
592
+ aria-label="${menuItem.title}"
593
+ >
594
+ ${menuItem.icon ? `<i class="fa-solid fa-${menuItem.icon}" aria-hidden="true"></i>` : ""}
595
+ </div>`
596
+ }
597
+
598
+ destroy() {
599
+ // Remove all event listeners
600
+ document.body.removeEventListener("click", this.listeners.onclick)
601
+ document.body.removeEventListener("input", this.listeners.oninput)
602
+ document.body.removeEventListener("keydown", this.listeners.onKeydown)
603
+ document.body.removeEventListener("focus", this.listeners.onFocus)
604
+
605
+ // Clear references
606
+ this.listeners = {}
607
+ this.keyboardShortcuts.clear()
608
+ this.menuEl = null
609
+ this.openedMenu = false
610
+ }
611
+ }
@@ -0,0 +1,18 @@
1
+ let _settings = null
2
+
3
+ export function initSettings(rawSettings) {
4
+ if (_settings) {
5
+ throw new Error("Settings already initialized")
6
+ }
7
+ // Freeze to prevent accidental mutation at runtime
8
+ _settings = Object.freeze({...rawSettings})
9
+ }
10
+
11
+ export function getSettings() {
12
+ if (!_settings) {
13
+ throw new Error(
14
+ "App settings not initialized. Call initSettings() first."
15
+ )
16
+ }
17
+ return _settings
18
+ }
@@ -0,0 +1,42 @@
1
+ import {avatarTemplate} from "./user"
2
+ import {filterPrimaryEmail} from "./user_util"
3
+
4
+ export const baseBodyTemplate = ({user, contents, hasOverview, app}) => `
5
+ <div id="wait">
6
+ <i class="fa fa-spinner fa-pulse"></i>
7
+ </div>
8
+ <header class="fw-header" role="banner">
9
+ <div class="fw-container">
10
+ <a href="${app && app.routes[""].app === "document" ? "/" : "/documents/"}">
11
+ <h1 class="fw-logo">
12
+ <span class="fw-logo-text"></span>
13
+ <img src="${staticUrl("svg/icon.svg")}" alt="Logo" />
14
+ </h1>
15
+ </a>
16
+ <nav id="header-nav" role="navigation" aria-label="${gettext("Site navigation")}"></nav>
17
+ <div id="user-preferences" class="fw-user-preferences fw-header-text">
18
+ <div id="preferences-btn" class="fw-button">
19
+ ${avatarTemplate({user})}
20
+ </div>
21
+ <div id="user-preferences-pulldown" class="fw-pulldown fw-right">
22
+ <div data-value="profile">
23
+ <span class='fw-avatar-card'>
24
+ <span class='fw-avatar-card-avatar'>${avatarTemplate({user})}</span>
25
+ <span class='fw-avatar-card-name'>
26
+ ${user.username}
27
+ <span class='fw-avatar-card-email'>${filterPrimaryEmail(user.emails)}</span>
28
+ </span>
29
+ </span>
30
+ </div>
31
+ <div data-value="contacts">${gettext("Contacts")}</div>
32
+ <div data-value="logout">${gettext("Log out")}</div>
33
+ </div>
34
+ </div><!-- end user preference -->
35
+ </div><!-- end container -->
36
+ </header>
37
+ <div class="fw-contents-outer">
38
+ ${hasOverview ? '<div class="fw-overview-menu-wrapper"><ul id="fw-overview-menu"></ul></div>' : ""}
39
+ <div class="fw-contents">
40
+ ${contents}
41
+ </div>
42
+ </div>`