@samitouri / QOSami-HFS / commits / 7cad190d

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 +}