a11y: several improvements
Massimo Melina committed
Jan 13, 2024 at 10:06 UTC
7cad190d6612cc3cd4620c879cb073f04aa517e2
22 files changed
+103
-65
admin/src/App.ts
+12
-11
@@ -14,24 +14,26 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
14
import ConfigFilePage from './ConfigFilePage'
15
import { useSnapState } from './state'
16
import { useEventListener } from 'usehooks-ts'
17
-import { xlate } from './misc'
17
+import { AriaOnly, xlate } from './misc'
18
19
function App() {
20
return h(ThemeProvider, { theme: useMyTheme() },
21
h(ApplyTheme, {},
22
h(LocalizationProvider, { dateAdapter: AdapterDayjs },
23
h(LoginRequired, {},
24
- h(HashRouter, {}, h(Routed)) ) ) ) )
24
+ h(HashRouter, {},
25
+ h(Dialogs, {
26
+ style: {
27
+ display: 'flex', flexDirection: 'column',
28
+ minHeight: '100%', flex: 1,
29
+ maxWidth: '100%',
30
+ }
31
+ }, h(Routed) ))) )))
32
}
33
34
function ApplyTheme(props:any) {
35
return h(Box, {
29
- sx: {
30
- bgcolor: 'background.default', color: 'text.primary',
31
- display: 'flex', flexDirection: 'column',
32
- minHeight: '100%', flex: 1,
33
- maxWidth: '100%',
34
- },
36
+ sx: { bgcolor: 'background.default', color: 'text.primary', flex: 1, },
37
...props
38
})
39
}
@@ -54,7 +56,7 @@ function Routed() {
56
navigate(path || '/')
57
})
58
return h(Fragment, {},
57
- h('h1', { hidden: true }, "Admin-panel"),
59
+ h(AriaOnly, {}, h('h1', {}, "Admin-panel")),
60
!large && h(StickyBar, { title, openMenu: () => setOpen(true) }),
61
!large && h(Drawer, { anchor:'left', open, onClose(){ setOpen(false) } },
62
h(MainMenu, {
@@ -84,7 +86,6 @@ function Routed() {
86
h(Route, { path: 'edit', element: h(ConfigFilePage) })
87
)
88
),
87
- h(Dialogs)
89
)
90
)
91
}
@@ -105,4 +106,4 @@ function StickyBar({ title, openMenu }: { title?: string, openMenu: ()=>void })
106
)
107
}
108
108
-export default App
109
+export default App
\ No newline at end of file
admin/src/FileForm.ts
+2
-1
@@ -87,7 +87,8 @@ export default function FileForm({ file, addToBar, statusApi }: FileFormProps) {
87
h(IconBtn, {
88
icon: ContentCut,
89
disabled: isRoot || movingFile === file.id,
90
- title: "You can also use drag & drop to move items",
90
+ title: "Cut (you can also use drag & drop to move items)",
91
+ 'aria-label': "Cut",
92
onClick() {
93
state.movingFile = file.id
94
alertDialog(h(Box, {}, "Now that this is marked for moving, click on the destination folder, and then the paste button ", h(ContentPaste)), 'info')
admin/src/HomePage.ts
+1
-1
@@ -62,7 +62,7 @@ export default function HomePage() {
62
: entry('success', "Server is working"),
63
!vfs ? h(LinearProgress)
64
: !vfs.root?.children?.length && !vfs.root?.source ? entry('warning', "You have no files shared", SOLUTION_SEP, fsLink("add some"))
65
- : entry('', md("This is Admin-panel, where you manage your server. Access your files on "),
65
+ : entry('', md("This is the Admin-panel, where you manage your server. Access your files on "),
66
h(Link, { target:'frontend', href: '../..' }, "Front-end", h(Launch, { sx: { verticalAlign: 'sub', ml: '.2em' } }))),
67
!href && entry('warning', "Frontend unreachable: ",
68
_.map(serverErrors, (v,k) => k + " " + (v ? "is in error" : "is off")).join(', '),
admin/src/InstalledPlugins.ts
+1
-1
@@ -64,7 +64,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
64
] : [
65
h(IconBtn, row.started ? {
66
icon: StopCircle,
67
- title: h(Box, {}, `Stop ${id}`, h('br'), `Started ` + new Date(row.started as string).toLocaleString()),
67
+ title: h(Box, {}, `Stop ${id}`, h('div', { 'aria-hidden': true }, `Started ` + new Date(row.started as string).toLocaleString())),
68
size,
69
color: 'success',
70
async onClick() {
admin/src/MainMenu.ts
+1
-1
@@ -43,7 +43,7 @@ interface MenuEntry {
43
}
44
45
export const mainMenu: MenuEntry[] = [
46
- { path: '', icon: Home, label: "Home", title: "Admin panel", comp: HomePage },
46
+ { path: '', icon: Home, label: "Home", comp: HomePage },
47
{ path: 'fs', icon: AccountTree, label: "Shared files", comp: VfsPage },
48
{ path: 'accounts', icon: ManageAccounts, comp: AccountsPage },
49
{ path: 'options', icon: Settings, comp: OptionsPage },
admin/src/MenuButton.ts
+13
-9
@@ -1,29 +1,33 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import React, { createElement as h, useCallback } from 'react'
4
-import { Button, Menu, MenuItem } from '@mui/material'
3
+import { MouseEvent, createElement as h, Fragment, useCallback, useId, useState } from 'react'
4
+import { Button, ButtonProps, Menu, MenuItem } from '@mui/material'
5
6
-interface Props { items: any[], [rest:string]:any }
6
+interface Props extends ButtonProps { items: any[] }
7
8
export default function MenuButton({ items, ...rest }: Props) {
9
- const [anchorEl, setAnchorEl] = React.useState<HTMLElement>()
9
+ const [anchorEl, setAnchorEl] = useState<HTMLElement>()
10
const open = Boolean(anchorEl)
11
const onClose = useCallback(() => setAnchorEl(undefined), [])
12
- return h(React.Fragment, {},
12
+ const id = useId()
13
+ const menuId = useId()
14
+ return h(Fragment, {},
15
h(Button, {
14
- 'aria-controls': open ? 'basic-menu' : undefined,
16
+ id,
17
+ 'aria-controls': open ? menuId : undefined,
18
'aria-haspopup': 'true',
16
- 'aria-expanded': open ? 'true' : undefined,
17
- onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
19
+ 'aria-expanded': open ? true : undefined,
20
+ onClick: (event: MouseEvent<HTMLButtonElement>) => {
21
setAnchorEl(event.currentTarget)
22
},
23
...rest,
24
}),
25
h(Menu, {
26
+ id: menuId,
27
anchorEl,
28
open,
29
onClose,
26
- MenuListProps: { 'aria-labelledby': 'basic-button' },
30
+ MenuListProps: { 'aria-labelledby': id },
31
children: items.map((it,idx) =>
32
h(MenuItem, {
33
key: idx,
admin/src/dialog.ts
+1
-1
@@ -98,7 +98,7 @@ export function alertDialog(msg: ReactElement | string | Error, options?: AlertT
98
return h(Box, { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 },
99
isValidElement(msg) ? msg
100
: h(Box, { fontSize: 'large', lineHeight: '1.8em' }, String(msg)),
101
- h(Btn, { sx: { mt: 1 }, size: 'small', onClick: dialog.close }, 'Close')
101
+ h(Btn, { sx: { mt: 1 }, size: 'small', onClick: dialog.close }, "Close")
102
)
103
}
104
})
admin/src/index.scss
renamed
+2
@@ -1,3 +1,5 @@
1
+@use '../../shared/main';
2
+
3
:root { height: 100dvh; }
4
body {
5
margin: 0;
admin/src/index.ts
+1
-1
@@ -2,7 +2,7 @@
2
3
import { createElement as h, StrictMode } from 'react'
4
import { createRoot } from 'react-dom/client'
5
-import './index.css'
5
+import './index.scss'
6
import '@hfs/shared/min-crypto-polyfill'
7
import App from './App'
8
import { disableConsoleDebug } from '@hfs/shared'
admin/src/mui.ts
+8
-3
@@ -104,9 +104,10 @@ export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, l
104
title = disabled
105
if (link)
106
onClick = () => window.open(link)
107
+ disabled = Boolean(loading || progress || disabled)
108
let ret: ReturnType<FC> = h(IconButton, {
109
ref,
109
- disabled: Boolean(loading || progress || disabled),
110
+ disabled,
111
...rest,
112
sx: { height: 'fit-content', ...sx },
113
async onClick(...args) {
@@ -125,8 +126,10 @@ export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, l
126
}),
127
h(icon)
128
)
129
+ if (disabled)
130
+ ret = h('span', { role: 'button', 'aria-label': title + ', disabled' }, ret)
131
if (title)
129
- ret = h(Tooltip, { title, ...tooltipProps, children: h('span',{},ret) })
132
+ ret = h(Tooltip, { title, ...tooltipProps, children: ret })
133
return ret
134
})
135
@@ -180,8 +183,10 @@ export function Btn({ icon, title, onClick, disabled, progress, link, tooltipPro
183
}
184
}
185
})
186
+ if (disabled)
187
+ ret = h('span', { role: 'button', 'aria-label': title + ', disabled' }, ret)
188
if (title)
184
- ret = h(Tooltip, { title, ...tooltipProps, children: h('span',{},ret) })
189
+ ret = h(Tooltip, { title, ...tooltipProps, children: ret })
190
return ret
191
}
192
frontend/index.html
+1
-1
@@ -1,5 +1,5 @@
1
<!DOCTYPE html>
2
-<html lang="en">
2
+<html>
3
<head>
4
<meta charset="utf-8" />
5
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0" />
frontend/src/App.ts
+4
-3
@@ -22,11 +22,12 @@ function App() {
22
return h(I18Nprovider, {},
23
h(BrowserRouter, {},
24
h(NavigationExtractor, {},
25
- h(Routes, {},
26
- h(Route, { path:'*', element: h(BrowseFiles) })
25
+ h(Dialogs, {},
26
+ h(Routes, {},
27
+ h(Route, { path:'*', element: h(BrowseFiles) })
28
+ ),
29
),
30
),
29
- h(Dialogs),
31
)
32
)
33
}
frontend/src/BrowseFiles.ts
+17
-16
@@ -1,9 +1,10 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { Link, useNavigate } from 'react-router-dom'
4
-import { createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+import { Link } from 'react-router-dom'
4
+import { createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState,
5
+ useId} from 'react'
6
import { useWindowSize } from 'usehooks-ts'
6
-import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy } from './misc'
7
+import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix } from './misc'
8
import { Checkbox, CustomCode, Spinner } from './components'
9
import { Head } from './Head'
10
import { DirEntry, state, useSnapState } from './state'
@@ -187,11 +188,11 @@ const PAGE_SEPARATOR_CLASS = 'page-separator'
188
189
interface EntryProps { entry: DirEntry, midnight: Date, separator?: string }
190
const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
190
- const { uri, isFolder } = entry
191
+ const { uri, isFolder, name } = entry
192
const { showFilter, selected, file_menu_on_link } = useSnapState()
193
const isLink = Boolean(entry.url)
194
const containerDir = isFolder || isLink ? '' : uri.substring(0, (uri.lastIndexOf('/') || -1) +1)
194
- const containerName = containerDir && entry.n.slice(0, -entry.name.length)
195
+ const containerName = containerDir && entry.n.slice(0, -name.length)
196
let className = isFolder ? 'folder' : 'file'
197
if (entry.cantOpen)
198
className += ' cant-open'
@@ -201,11 +202,13 @@ const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
202
const onClick = !isLink && !entry.web && file_menu_on_link && fileMenu || undefined
203
const small = useWindowSize().width < 800
204
const showingButton = !file_menu_on_link || isFolder && small
205
+ const ariaId = useId()
206
+ const ariaProps = { id: ariaId, 'aria-label': prefix(name + ' (', isFolder ? "Folder" : entry.web ? "Web page" : isLink ? "Link" : '', ')') }
207
return h('li', { className, label: separator },
208
h(CustomCode, { name: 'entry', props: { entry }, ifEmpty: () => h(Fragment, {},
209
showFilter && h(Checkbox, {
210
disabled: isLink,
208
- 'aria-label': entry.name,
211
+ 'aria-labelledby': ariaId,
212
value: selected[uri],
213
onChange(v){
214
if (v)
@@ -215,15 +218,15 @@ const Entry = memo(({ entry, midnight, separator }: EntryProps) => {
218
}),
219
h('span', { className: 'link-wrapper' }, // container to handle mouse over for both children
220
isFolder && !entry.web ? h(Fragment, {}, // internal navigation, use Link component
218
- h(Link, { to: uri }, ico, entry.n.slice(0,-1)),
221
+ h(Link, { to: uri, ...ariaProps }, ico, entry.n.slice(0,-1)), // don't use name, as we want to include whole path in case of search
222
// popup button is here to be able to detect link-wrapper:hover
223
file_menu_on_link && !showingButton && h('button', { className: 'popup-menu-button', onClick: fileMenu }, hIcon('menu'), t`Menu`)
224
)
222
- : containerDir ? h(Fragment, {},
225
+ : containerName ? h(Fragment, {},
226
h('a', { href: uri, onClick, tabIndex: -1 }, ico),
227
h(Link, { to: containerDir, className:'container-folder', tabIndex: -1 }, containerName),
225
- h('a', { href: uri, onClick }, entry.name)
226
- ) : h('a', { href: uri, onClick }, ico, entry.name),
228
+ h('a', { href: uri, onClick, ...ariaProps }, name)
229
+ ) : h('a', { href: uri, onClick, ...ariaProps }, ico, name),
230
),
231
h(CustomCode, { name: 'afterEntryName', props: { entry } }),
232
entry.comment && h('div', { className: 'entry-comment' }, entry.comment),
@@ -267,6 +270,7 @@ export const EntryDetails = memo(({ entry, midnight }: { entry: DirEntry, midnig
270
h(EntrySize, { s }),
271
time && h('span', {
272
className: 'entry-ts',
273
+ 'aria-hidden': true,
274
onClick() { // mobile has no hover
275
if (shortTs)
276
alertDialog(t`Full timestamp:` + "\n" + time.toLocaleString()).then()
@@ -278,9 +282,6 @@ export const EntryDetails = memo(({ entry, midnight }: { entry: DirEntry, midnig
282
)
283
})
284
281
-const EntrySize = memo(({ s }: { s: DirEntry['s'] }) => {
282
- if (s === undefined) return null
283
- const a = formatBytes(s).split(' ')
284
- return h('span', { className: 'entry-size', title: s.toLocaleString() }, a[0],
285
- h('span', { className: 'entry-size-unit' }, a[1]))
286
-})
\ No newline at end of file
285
+const EntrySize = memo(({ s }: { s: DirEntry['s'] }) =>
286
+ s === undefined ? null
287
+ : h('span', { className: 'entry-size', ...noAriaTitle(s.toLocaleString()) }, formatBytes(s)))
frontend/src/FilterBar.ts
+1
@@ -22,6 +22,7 @@ export function FilterBar() {
22
h(Checkbox, {
23
value: all,
24
tabIndex,
25
+ 'aria-hidden': !showFilter,
26
'aria-label': "Select all",
27
onContextMenu(ev) {
28
ev.preventDefault()
frontend/src/i18n.ts
+2
-2
@@ -1,7 +1,7 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { findDefined, getHFS } from './misc'
4
-import { createElement as h, Fragment } from 'react'
4
+import { createElement as h } from 'react'
5
import { proxy, useSnapshot } from 'valtio'
6
7
const translations = getHFS().lang || {} // all dictionaries
@@ -19,7 +19,7 @@ export function useI18N() {
19
20
export function I18Nprovider({ embedded='en', ...props }) {
21
state.embedded = embedded
22
- return h(Fragment, props)
22
+ return h('div', { lang: state.langs[0] || embedded, ...props })
23
}
24
25
// If one of the keys is an "id", that should be the first. If one of the keys should work as a fallback, that should be the last. Use 'fallback' parameter if you don't want the fallback to work as a key.
frontend/src/icons.ts
+2
-2
@@ -50,7 +50,7 @@ document.fonts.ready.then(async ()=> {
50
})
51
52
interface IconProps { name:string, className?:string, alt?:string, [rest:string]: any }
53
-export const Icon = memo(({ name, alt='', className='', ...props }: IconProps) => {
53
+export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => {
54
if (!name) return null
55
const [emoji, clazz=name] = SYS_ICONS[name] || []
56
const { iconsReady } = useSnapState()
@@ -61,7 +61,7 @@ export const Icon = memo(({ name, alt='', className='', ...props }: IconProps) =
61
const isFontIcon = iconsReady && clazz
62
className += nameIsFile ? ' file-icon' : isFontIcon ? ` fa-${clazz}` : ' emoji-icon'
63
return h('span',{
64
- 'aria-label': alt,
64
+ ...alt ? { 'aria-label': alt } : { 'aria-hidden': true },
65
role: 'img',
66
...props,
67
...nameIsFile ? { style: { backgroundImage: `url(${JSON.stringify(name)})`, ...props?.style } } : undefined,
frontend/src/index.scss
+6
-4
@@ -1,5 +1,7 @@
1
+@use '../../shared/main';
2
+
3
:root {
2
- height: 100dvh; /* workarounded chrome109-mobile's problem with sticky-bottom bar moving when scrolling */
4
+ height: 100dvh; // workarounded chrome109-mobile's problem with sticky-bottom bar moving when scrolling
5
6
--bg: #fff;
7
--text: #555;
@@ -11,7 +13,7 @@
13
--button-bg: #6080aa;
14
--button-text: #eaeaea;
15
--focus-color: #468;
14
- --separator: " – ";
16
+ --separator: " – " / ""; // skip screen-reader
17
.highlightedText { color: #0006; text-shadow: 0 0 3px #0006; }
18
.theme-dark {
19
--bg: #000;
@@ -374,7 +376,7 @@ ul.dir {
376
}
377
}
378
377
-button label {
379
+button .label {
380
cursor: inherit;
381
margin-left: .4em;
382
}
@@ -702,7 +704,7 @@ button label {
704
:root { --ghost-contrast: #8883; } /* phones have different curve */
705
body, button, select { font-size: 14pt; }
706
#menu-bar, #filter-bar, #clipBar {
705
- button label { display: none } /* icons only */
707
+ button .label { display: none } /* icons only */
708
}
709
#filter-bar {
710
margin-top: 0.4em;
frontend/src/login.ts
+4
-2
@@ -70,9 +70,10 @@ export async function loginDialog() {
70
},
71
h(CustomCode, { name: 'beforeLogin' }),
72
h('div', { className: 'field' },
73
- h('label', { htmlFor: 'username' }, t`Username`),
73
+ h('label', { htmlFor: 'login_username' }, t`Username`),
74
h('input', {
75
ref: usrRef,
76
+ id: 'login_username',
77
name: 'username',
78
autoComplete: 'username',
79
required: true,
@@ -80,9 +81,10 @@ export async function loginDialog() {
81
}),
82
),
83
h('div', { className: 'field' },
83
- h('label', { htmlFor: 'password' }, t`Password`),
84
+ h('label', { htmlFor: 'login_password' }, t`Password`),
85
h('input', {
86
ref: pwdRef,
87
+ id: 'login_password',
88
name: 'password',
89
type: 'password',
90
autoComplete: 'current-password',
frontend/src/menu.ts
+2
-2
@@ -169,7 +169,7 @@ export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation,
169
className: [rest.className, toggled && 'toggled', working && 'ani-working'].filter(Boolean).join(' '),
170
...toggled !== undefined && { 'aria-pressed': toggled },
171
...rest,
172
- }, hIcon(icon), h('label', {}, label) )
172
+ }, hIcon(icon), h('span', { className: 'label' }, label) ) // don't use <label> as VoiceOver will get redundant
173
}
174
175
export function MenuLink({ href, target, confirm, confirmOptions, ...rest }: MenuButtonProps & { href: string, target?: string, confirm?: string, confirmOptions?: ConfirmOptions }) {
@@ -190,7 +190,7 @@ function LoginButton() {
190
const {t} = useI18N()
191
return Btn(snap.username ? {
192
id: 'user-button',
193
- toggled: true,
193
+ className: 'toggled', // without aria-pressed
194
icon: 'user',
195
label: snap.username,
196
onClick: showUserPanel
shared/_main.scss
new
+5
@@ -0,0 +1,5 @@
1
+.ariaOnly {
2
+ position: absolute;
3
+ clipPath: rect(1px 1px 1px 1px);
4
+ clip: rect(1px, 1px, 1px, 1px); // legacy browsers
5
+}
shared/dialogs.ts
+5
-3
@@ -1,6 +1,7 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, Fragment, FunctionComponent, isValidElement, ReactNode, useEffect, useRef } from 'react'
3
+import { createElement as h, Fragment, FunctionComponent, isValidElement, ReactNode, useEffect, useRef,
4
+ HTMLAttributes} from 'react'
5
import { proxy, ref, useSnapshot } from 'valtio'
6
import { isPrimitive, objSameKeys } from '.'
7
@@ -55,13 +56,14 @@ function isDescendant(child: Node | null, parent: Node) {
56
return false
57
}
58
58
-export function Dialogs() {
59
+export function Dialogs(props: HTMLAttributes<HTMLDivElement>) {
60
const snap = useSnapshot(dialogs)
61
useEffect(() => {
62
document.body.style.overflow = snap.length ? 'hidden' : ''
63
}, [snap.length])
64
return h(Fragment, {},
64
- snap.length > 0 && snap.map(d =>
65
+ h('div', { 'aria-hidden': snap.length > 0, ...props }),
66
+ snap.map(d =>
67
h(Dialog, { key: d.$id, ...(d as DialogOptions) })))
68
}
69
shared/react.ts
+12
-1
@@ -97,7 +97,18 @@ export function KeepInScreen({ margin, ...props }: any) {
97
return h('div', { ref, style: { maxHeight, overflow: 'auto' }, ...props })
98
}
99
100
+export function AriaOnly({ children }: { children?: ReactNode }) {
101
+ return children ? h('div', { className: 'ariaOnly' }, children) : null
102
+}
103
+
104
+export function noAriaTitle(title: string) {
105
+ return {
106
+ onMouseEnter(ev: any) {
107
+ ev.target.title = title
108
+ }
109
+ }
110
+}
111
const isMac = navigator.platform.match('Mac')
112
export function isCtrlKey(ev: KeyboardEvent) {
113
return (ev.ctrlKey || isMac && ev.metaKey) && ev.key
103
-}
\ No newline at end of file
114
+}