Skip to content

Commit 3de681d

Browse files
ShroXdghostdevv
andauthored
feat(a11y): add reusable Alert component (#1955)
Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent 3712560 commit 3de681d

File tree

3 files changed

+106
-42
lines changed

3 files changed

+106
-42
lines changed

app/components/Alert.vue

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<script setup lang="ts">
2+
interface Props {
3+
variant: 'warning' | 'error'
4+
title?: string
5+
}
6+
7+
defineProps<Props>()
8+
9+
const WRAPPER_CLASSES: Record<Props['variant'], string> = {
10+
warning: 'border-amber-400/20 bg-amber-500/8',
11+
error: 'border-red-400/20 bg-red-500/8',
12+
}
13+
14+
const TITLE_CLASSES: Record<Props['variant'], string> = {
15+
warning: 'text-amber-800 dark:text-amber-300',
16+
error: 'text-red-800 dark:text-red-300',
17+
}
18+
19+
const BODY_CLASSES: Record<Props['variant'], string> = {
20+
warning: 'text-amber-700 dark:text-amber-400',
21+
error: 'text-red-700 dark:text-red-400',
22+
}
23+
24+
const ROLES: Record<Props['variant'], 'status' | 'alert'> = {
25+
warning: 'status',
26+
error: 'alert',
27+
}
28+
</script>
29+
30+
<template>
31+
<div
32+
:role="ROLES[variant]"
33+
class="border rounded-md px-3 py-2.5"
34+
:class="WRAPPER_CLASSES[variant]"
35+
>
36+
<p v-if="title" class="font-semibold mb-1" :class="TITLE_CLASSES[variant]">{{ title }}</p>
37+
<div class="text-xs" :class="BODY_CLASSES[variant]">
38+
<slot />
39+
</div>
40+
</div>
41+
</template>

app/components/Package/ClaimPackageModal.vue

Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -199,28 +199,26 @@ const previewPackageJson = computed(() => {
199199
</div>
200200

201201
<!-- Validation errors -->
202-
<div
202+
<Alert
203203
v-if="checkResult.validationErrors?.length"
204-
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
205-
role="alert"
204+
variant="error"
205+
:title="$t('claim.modal.invalid_name')"
206206
>
207-
<p class="font-medium mb-1">{{ $t('claim.modal.invalid_name') }}</p>
208207
<ul class="list-disc list-inside space-y-1">
209208
<li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li>
210209
</ul>
211-
</div>
210+
</Alert>
212211

213212
<!-- Validation warnings -->
214-
<div
213+
<Alert
215214
v-if="checkResult.validationWarnings?.length"
216-
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
217-
role="alert"
215+
variant="warning"
216+
:title="$t('common.warnings')"
218217
>
219-
<p class="font-medium mb-1">{{ $t('common.warnings') }}</p>
220218
<ul class="list-disc list-inside space-y-1">
221219
<li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li>
222220
</ul>
223-
</div>
221+
</Alert>
224222

225223
<!-- Availability status -->
226224
<template v-if="checkResult.valid">
@@ -305,39 +303,23 @@ const previewPackageJson = computed(() => {
305303
</template>
306304

307305
<!-- Error message -->
308-
<div
309-
v-if="mergedError"
310-
role="alert"
311-
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
312-
>
313-
{{ mergedError }}
314-
</div>
306+
<Alert v-if="mergedError" variant="error">{{ mergedError }}</Alert>
315307

316308
<!-- Actions -->
317309
<div v-if="checkResult.available && checkResult.valid" class="space-y-3">
318310
<!-- Warning for unscoped packages -->
319-
<div
320-
v-if="!isScoped"
321-
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
322-
>
323-
<p class="font-medium mb-1">{{ $t('claim.modal.scope_warning_title') }}</p>
324-
<p class="text-xs text-yellow-400/80">
325-
{{
326-
$t('claim.modal.scope_warning_text', {
327-
username: npmUser || 'username',
328-
name: packageName,
329-
})
330-
}}
331-
</p>
332-
</div>
311+
<Alert v-if="!isScoped" variant="warning" :title="$t('claim.modal.scope_warning_title')">
312+
{{
313+
$t('claim.modal.scope_warning_text', {
314+
username: npmUser || 'username',
315+
name: packageName,
316+
})
317+
}}
318+
</Alert>
333319

334320
<!-- Not connected warning -->
335321
<div v-if="!isConnected" class="space-y-3">
336-
<div
337-
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
338-
>
339-
<p>{{ $t('claim.modal.connect_required') }}</p>
340-
</div>
322+
<Alert variant="warning">{{ $t('claim.modal.connect_required') }}</Alert>
341323
<button
342324
type="button"
343325
class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-accent/70"
@@ -389,12 +371,7 @@ const previewPackageJson = computed(() => {
389371

390372
<!-- Error state -->
391373
<div v-else-if="mergedError" class="space-y-4">
392-
<div
393-
role="alert"
394-
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
395-
>
396-
{{ mergedError }}
397-
</div>
374+
<Alert variant="error">{{ mergedError }}</Alert>
398375
<button
399376
type="button"
400377
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70"

test/nuxt/a11y.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ vi.mock('vue-data-ui/vue-ui-xy', () => {
116116
// Import components from #components where possible
117117
// For server/client variants, we need to import directly to test the specific variant
118118
import {
119+
Alert,
119120
AppFooter,
120121
AppHeader,
121122
AppLogo,
@@ -3529,6 +3530,35 @@ describe('component accessibility audits', () => {
35293530
expect(results.violations).toEqual([])
35303531
})
35313532
})
3533+
3534+
describe('Alert', () => {
3535+
it('should have no accessibility violations for warning variant', async () => {
3536+
const component = await mountSuspended(Alert, {
3537+
props: { variant: 'warning', title: 'Warning title' },
3538+
slots: { default: 'This is a warning message.' },
3539+
})
3540+
const results = await runAxe(component)
3541+
expect(results.violations).toEqual([])
3542+
})
3543+
3544+
it('should have no accessibility violations for error variant', async () => {
3545+
const component = await mountSuspended(Alert, {
3546+
props: { variant: 'error', title: 'Error title' },
3547+
slots: { default: 'This is an error message.' },
3548+
})
3549+
const results = await runAxe(component)
3550+
expect(results.violations).toEqual([])
3551+
})
3552+
3553+
it('should have no accessibility violations without title', async () => {
3554+
const component = await mountSuspended(Alert, {
3555+
props: { variant: 'warning' },
3556+
slots: { default: 'This is a warning message.' },
3557+
})
3558+
const results = await runAxe(component)
3559+
expect(results.violations).toEqual([])
3560+
})
3561+
})
35323562
})
35333563

35343564
function applyTheme(colorMode: string, bgTheme: string | null) {
@@ -3575,6 +3605,22 @@ describe('background theme accessibility', () => {
35753605
}
35763606

35773607
const components = [
3608+
{
3609+
name: 'AlertWarning',
3610+
mount: () =>
3611+
mountSuspended(Alert, {
3612+
props: { variant: 'warning', title: 'Warning title' },
3613+
slots: { default: '<p>Warning body</p>' },
3614+
}),
3615+
},
3616+
{
3617+
name: 'AlertError',
3618+
mount: () =>
3619+
mountSuspended(Alert, {
3620+
props: { variant: 'error', title: 'Error title' },
3621+
slots: { default: '<p>Error body</p>' },
3622+
}),
3623+
},
35783624
{ name: 'AppHeader', mount: () => mountSuspended(AppHeader) },
35793625
{ name: 'AppFooter', mount: () => mountSuspended(AppFooter) },
35803626
{ name: 'HeaderSearchBox', mount: () => mountSuspended(HeaderSearchBox) },

0 commit comments

Comments
 (0)