a11y: workaround for faulty JAWS on first open dialog

Massimo Melina committed Mar 31, 2024 at 11:01 UTC cd0d9308633f413c3ac4bde49dc1e9ad4217948b
2 files changed +17 -12
frontend/src/dialog.css
+1
@@ -43,6 +43,7 @@
43 }
44 .dialog-title {
45 font-size: 120%;
46 + font-weight: normal;
47 margin: .3em 0;
48 padding: 0 0.5em; /* avoid reaching margins */
49 min-height: 1.2em;
shared/dialogs.ts
+16 -12
@@ -37,17 +37,22 @@ export const focusableSelector = ['input:not([type="hidden"])', 'button', 'selec
37 x + ':not([disabled]):not([tabindex="-1"])').join(',')
38 window.addEventListener('keydown', ev => {
39 if (ev.key !== 'Tab') return
40 + if (tabCycle(ev.target, ev.shiftKey))
41 + ev.preventDefault()
42 +})
43 +
44 +function tabCycle(target: EventTarget | null, invert=false) {
45 const dialogs = document.querySelectorAll('[role$=dialog]')
46 const dialog = dialogs[dialogs.length-1]
47 if (!dialog) return
48 const focusable = dialog.querySelectorAll(focusableSelector)
49 const n = focusable.length
50 if (!n) return
46 - const [a, b] = ev.shiftKey ? [n-1, 0] : [0, n-1]
47 - if (ev.target !== focusable[b] && isDescendant(document.activeElement, dialog)) return // default behavior
51 + const [a, b] = invert ? [n-1, 0] : [0, n-1]
52 + if (target !== focusable[b] && isDescendant(document.activeElement, dialog)) return // default behavior
53 ;(focusable[a] as HTMLElement).focus()
49 - ev.preventDefault()
50 -})
54 + return true
55 +}
56
57 function isDescendant(child: Node | null, parent: Node) {
58 while (child) {
@@ -87,12 +92,12 @@ function Dialog(d: DialogOptions) {
92 const ref = useRef<HTMLElement>()
93 const [shiftY, setShiftY] = useState(0)
94 useEffect(()=>{
90 - if (!ref.current) return
91 - ref.current.focus()
92 - if (d.position) {
93 - const rect = ref.current.querySelector('.dialog')!.getBoundingClientRect()
94 - setShiftY(Math.min(0, rect.top, window.innerHeight - rect.bottom))
95 - }
95 + const el = ref.current?.querySelector('.dialog') as HTMLElement | undefined
96 + if (!el) return
97 + tabCycle(el) // focus first thing inside dialog. This makes JAWS behave
98 + if (!d.position) return
99 + const rect = el.getBoundingClientRect()
100 + setShiftY(Math.min(0, rect.top, window.innerHeight - rect.bottom))
101 }, [])
102 d = { closable: true, ...dialogsDefaults, ...d }
103 if (d.Container)
@@ -100,7 +105,6 @@ function Dialog(d: DialogOptions) {
105 return h('div', {
106 ref,
107 className: 'dialog-backdrop '+(d.className||''),
103 - tabIndex: 0,
108 onKeyDown,
109 onClick: (ev: any) => d.closable
110 && ev.target === ev.currentTarget // this test will tell us if really the backdrop was clicked
@@ -129,7 +133,7 @@ function Dialog(d: DialogOptions) {
133 className: 'dialog-icon dialog-type' + (typeof d.icon === 'string' ? ' dialog-icon-text' : ''),
134 'aria-hidden': true,
135 }, componentOrNode(d.icon)),
132 - h('div', { className: 'dialog-title' }, componentOrNode(d.title)),
136 + h('h1', { className: 'dialog-title' }, componentOrNode(d.title)),
137 h('div', { className: 'dialog-content' }, h(d.Content || 'div'))
138 )
139 )