Skip to content

Commit 7c6c585

Browse files
committed
fix: address accessibility report findings
1 parent ee32e5c commit 7c6c585

23 files changed

Lines changed: 535 additions & 242 deletions

components/Dialog/Dialog.module.css

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@
44
inset: 0;
55
}
66

7+
/* sr-only: visually hidden, still in the a11y tree as the dialog's name */
78
.title {
8-
display: none;
9+
position: absolute;
10+
width: 1px;
11+
height: 1px;
12+
padding: 0;
13+
margin: -1px;
14+
overflow: hidden;
15+
clip: rect(0, 0, 0, 0);
16+
white-space: nowrap;
17+
border: 0;
918
}
1019

1120
.content {
@@ -30,12 +39,6 @@
3039
margin: 0 auto;
3140
}
3241

33-
.title {
34-
font-size: 20px;
35-
font-weight: bold;
36-
margin-bottom: 16px;
37-
}
38-
3942
.description {
4043
margin-bottom: 16px;
4144
height: 100%;

components/Dialog/Dialog.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import classes from "./Dialog.module.css"
77

88
export function Dialog(props: {
99
open?: boolean
10+
title: string
1011
children: ReactNode
1112
fullScreen?: boolean
1213
onOpenChange: (open: boolean) => void
14+
// Forwarded to Radix/Vaul. Call event.preventDefault() to take over focus
15+
// restoration (e.g. focus the editor instead of the trigger).
16+
onCloseAutoFocus?: (event: Event) => void
1317
}) {
1418
const snapPoints = [0.5, 1] as const
1519
const [snap, setSnap] = useState<number | string | null>(snapPoints[0])
@@ -29,12 +33,17 @@ export function Dialog(props: {
2933
>
3034
<VaulDrawer.Portal>
3135
<VaulDrawer.Overlay className={classes.overlay} />
32-
<VaulDrawer.Content className={classes.content}>
36+
<VaulDrawer.Content
37+
className={classes.content}
38+
onCloseAutoFocus={props.onCloseAutoFocus}
39+
>
3340
<Container size="lg" h="100%">
3441
<Flex gap={{ base: 20, md: 40 }} direction="column" h="100%">
3542
<Box className={classes.handle} />
43+
{/* Visually hidden but exposed to AT — provides the dialog's
44+
accessible name (WCAG 4.1.3 Status Messages). */}
3645
<VaulDrawer.Title className={classes.title}>
37-
<Trans>Dialog</Trans>
46+
{props.title}
3847
</VaulDrawer.Title>
3948
<ScrollArea
4049
flex={props.fullScreen ? 1 : undefined}
@@ -43,6 +52,7 @@ export function Dialog(props: {
4352
{props.children}
4453
</ScrollArea>
4554
<Button
55+
className="hover-bump"
4656
onClick={() => props.onOpenChange(false)}
4757
color="gray"
4858
fullWidth

components/Form/Form.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,37 @@ import { Trans, useLingui } from "@lingui/react/macro"
22
import { Box, Button, CloseButton, Stack, Tabs } from "@mantine/core"
33
import { FileInput, TextInput, Textarea } from "@mantine/core"
44
import { isJSONString, isNotEmpty, useForm } from "@mantine/form"
5-
import { useState } from "react"
65
import * as icons from "#icons.ts"
76
import * as settings from "#settings.ts"
87

8+
export type ActiveTab = "url" | "file" | "text"
9+
910
export interface FormProps {
1011
onSubmit: (value: string | File | Record<string, any>) => void
12+
activeTab?: ActiveTab
13+
onActiveTabChange?: (tab: ActiveTab) => void
1114
}
1215

16+
// Live-region props for inline validation errors so screen readers announce them
17+
// the moment the message appears (WCAG 4.1.3 Status Messages, 3.3.1 Error Identification).
18+
const errorProps = { role: "alert", "aria-live": "polite" as const }
19+
1320
export function Form(props: FormProps) {
14-
const [activeTab, setActiveTab] = useState<string | null>("url")
21+
const activeTab = props.activeTab ?? "url"
22+
23+
const handleTabChange = (value: string | null) => {
24+
if (value === "url" || value === "file" || value === "text") {
25+
props.onActiveTabChange?.(value)
26+
}
27+
}
1528

1629
return (
17-
<Tabs defaultValue="url" value={activeTab} onChange={setActiveTab}>
30+
<Tabs
31+
value={activeTab}
32+
onChange={handleTabChange}
33+
activateTabWithKeyboard={false}
34+
classNames={{ tab: "hover-bump-tab" }}
35+
>
1836
<Tabs.List>
1937
<Tabs.Tab
2038
value="url"
@@ -94,11 +112,15 @@ function UrlForm(props: { onSubmit: (value: string) => void }) {
94112
<form onSubmit={handleSubmit}>
95113
<Stack gap="md">
96114
<TextInput
115+
id="input-url"
97116
placeholder={t`Enter data package URL`}
98117
size="lg"
99118
label={t`Data Package URL`}
119+
errorProps={errorProps}
100120
rightSection={
101121
<CloseButton
122+
className="hover-bump"
123+
aria-label={t`Clear URL`}
102124
onClick={() => form.setFieldValue("url", "")}
103125
disabled={!form.values.url}
104126
/>
@@ -139,11 +161,15 @@ function FileForm(props: { onSubmit: (value: File) => void }) {
139161
<form onSubmit={handleSubmit}>
140162
<Stack gap="md">
141163
<FileInput
164+
id="input-file"
142165
placeholder={t`Select data package file`}
143166
size="lg"
144167
label={t`Data Package File`}
168+
errorProps={errorProps}
145169
rightSection={
146170
<CloseButton
171+
className="hover-bump"
172+
aria-label={t`Clear file selection`}
147173
onClick={() => form.setFieldValue("file", null)}
148174
disabled={!form.values.file}
149175
/>
@@ -180,14 +206,18 @@ function JsonForm(props: { onSubmit: (value: Record<string, any>) => void }) {
180206
<form onSubmit={handleSubmit}>
181207
<Stack gap="md">
182208
<Textarea
209+
id="input-text"
183210
placeholder={t`Paste data package JSON`}
184211
size="lg"
185212
label={t`Data Package JSON`}
186213
autosize
187214
minRows={5}
215+
errorProps={errorProps}
188216
rightSection={
189217
<Box mt="xs" style={{ alignSelf: "flex-start" }}>
190218
<CloseButton
219+
className="hover-bump"
220+
aria-label={t`Clear JSON input`}
191221
onClick={() => form.setFieldValue("text", "")}
192222
disabled={!form.values.text}
193223
/>
@@ -202,13 +232,10 @@ function JsonForm(props: { onSubmit: (value: Record<string, any>) => void }) {
202232
}
203233

204234
function SubmitButton(props: { disabled: boolean }) {
235+
// Always render filled so contrast meets 4.5:1 in both light and dark.
236+
// The light variant fallback failed WCAG 1.4.3.
205237
return (
206-
<Button
207-
type="submit"
208-
size="lg"
209-
variant={props.disabled ? "light" : "filled"}
210-
disabled={props.disabled}
211-
>
238+
<Button type="submit" size="lg" variant="filled" disabled={props.disabled}>
212239
<Trans>Validate Data Package</Trans>
213240
</Button>
214241
)

components/Layout/Content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Container } from "@mantine/core"
22

33
export function Content(props: { children: React.ReactNode }) {
44
return (
5-
<Container size="lg" w="100%">
5+
<Container component="main" id="main-content" size="lg" w="100%">
66
{props.children}
77
</Container>
88
)

components/Layout/Layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { Content } from "./Content.tsx"
44
import { Footer } from "./Footer.tsx"
55
import { Header } from "./Header.tsx"
66
import classes from "./Layout.module.css"
7+
import { SkipLink } from "./SkipLink.tsx"
78

89
export function Layout(props: {
910
children?: React.ReactNode
1011
}) {
1112
return (
1213
<Stack gap={40} className={classes.root}>
14+
<SkipLink />
1315
<Box>
1416
<Header />
1517
<Banner />

components/Layout/Logo.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useLingui } from "@lingui/react/macro"
12
import { Anchor } from "@mantine/core"
23
import { useComputedColorScheme } from "@mantine/core"
34
import { Link } from "react-router"
@@ -8,6 +9,7 @@ import LogoIconLight from "#assets/datapackage-logo-light.svg?react"
89
import classes from "./Logo.module.css"
910

1011
export function Logo() {
12+
const { t } = useLingui()
1113
const colorScheme = useComputedColorScheme("light")
1214

1315
return (
@@ -18,11 +20,12 @@ export function Logo() {
1820
component={Link}
1921
className={classes.color}
2022
underline="never"
23+
aria-label={t`Data Package homepage`}
2124
>
2225
{colorScheme === "dark" ? (
23-
<LogoIconDark height="40px" />
26+
<LogoIconDark height="40px" aria-hidden="true" focusable="false" />
2427
) : (
25-
<LogoIconLight height="40px" />
28+
<LogoIconLight height="40px" aria-hidden="true" focusable="false" />
2629
)}
2730
</Anchor>
2831
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* sr-only-focusable: hidden until keyboard focus, then surfaces in the
2+
top-left so keyboard users can bypass the header & banner (WCAG 2.4.1). */
3+
.link {
4+
position: absolute;
5+
left: -9999px;
6+
top: 0;
7+
z-index: 200;
8+
padding: 8px 12px;
9+
background-color: light-dark(white, var(--mantine-color-dark-6));
10+
color: light-dark(black, var(--mantine-color-gray-0));
11+
text-decoration: none;
12+
border-radius: 4px;
13+
}
14+
15+
.link:focus,
16+
.link:focus-visible {
17+
left: 12px;
18+
top: 12px;
19+
outline: 2px solid var(--mantine-primary-color-filled);
20+
outline-offset: 2px;
21+
}

components/Layout/SkipLink.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Trans } from "@lingui/react/macro"
2+
import classes from "./SkipLink.module.css"
3+
4+
export function SkipLink() {
5+
return (
6+
<a href="#main-content" className={classes.link}>
7+
<Trans>Skip to main content</Trans>
8+
</a>
9+
)
10+
}

components/Report/Report.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function Report(props: {
3232
variant="pills"
3333
value={selectedType}
3434
onChange={value => setSelectedType(value ?? "all")}
35+
activateTabWithKeyboard={false}
3536
>
3637
<Stack gap="lg">
3738
<Divider label={<Trans>Errors</Trans>} labelPosition="center" />

components/Status/Status.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ export function Status(props: StatusProps) {
3434
<Center className={classes.container}>
3535
<Flex direction={{ base: "column", sm: "row" }} align="center" gap="md">
3636
{getIcon()}
37-
<span className={classes.title}>{getTitle()}</span>
37+
{/* output is implicitly role=status — announces title transitions
38+
(pending → valid/invalid) without bleeding Mantine's injected
39+
responsive <style> into the live region's textContent. */}
40+
<output aria-live="polite" aria-atomic="true" className={classes.title}>
41+
{getTitle()}
42+
</output>
3843
</Flex>
3944
</Center>
4045
)

0 commit comments

Comments
 (0)