feat(client): add accessible design system and API client foundation

Add design tokens, base components (page header, section, status chip, empty state, loading, announcer), a11y composables (text scaling, announcements), high-contrast theme and a typed HTTP client with ProblemDetails error mapping.
This commit is contained in:
Rafał Tyński
2026-10-04 04:04:31 +02:00
parent 4b0225008c
commit 92a904b8a9
18 changed files with 455 additions and 4 deletions
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { announcement } from '../composables/useAnnounce'
</script>
<template>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ announcement }}</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
text?: string
icon?: string
}>(),
{ text: undefined, icon: 'mdi-information-outline' },
)
</script>
<template>
<div class="text-center pa-6">
<v-icon :icon="icon" size="48" color="primary" aria-hidden="true" class="mb-3" />
<h3 class="text-h6 mb-2">{{ title }}</h3>
<p v-if="text" class="text-body-2 mb-4">{{ text }}</p>
<div v-if="$slots.default" class="d-flex justify-center">
<slot />
</div>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
label?: string
}>(),
{ label: undefined },
)
const { t } = useI18n()
</script>
<template>
<div
class="d-flex flex-column align-center justify-center pa-8"
role="status"
aria-live="polite"
:aria-busy="true"
>
<v-progress-circular indeterminate color="primary" size="48" aria-hidden="true" />
<p class="text-body-2 mt-4 mb-0">{{ props.label ?? t('common.loading') }}</p>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
title: string
lead?: string
}>()
</script>
<template>
<header class="mb-6 mb-md-8">
<h1 class="text-h4 text-md-h3 mb-2">{{ title }}</h1>
<p v-if="lead" class="text-body-1 mb-0">{{ lead }}</p>
<div v-if="$slots.actions" class="th-cluster mt-4">
<slot name="actions" />
</div>
</header>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useId } from 'vue'
defineProps<{
title: string
}>()
const headingId = useId()
</script>
<template>
<section :aria-labelledby="headingId" class="mb-10">
<h2 :id="headingId" class="text-h5 mb-4">{{ title }}</h2>
<slot />
</section>
</template>
@@ -0,0 +1,17 @@
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import { describe, expect, it } from 'vitest'
import AppStatusChip from './AppStatusChip.vue'
const vuetify = createVuetify()
describe('AppStatusChip', () => {
it('conveys status as text, not only as colour', () => {
const wrapper = mount(AppStatusChip, {
props: { label: 'Zaakceptowany', color: 'success' },
global: { plugins: [vuetify] },
})
expect(wrapper.text()).toContain('Zaakceptowany')
})
})
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
icon?: string
color?: string
}>(),
{ icon: undefined, color: 'primary' },
)
</script>
<template>
<v-chip :color="color" :prepend-icon="icon" size="small" label>{{ label }}</v-chip>
</template>
+44
View File
@@ -0,0 +1,44 @@
import { computed, watch } from 'vue'
import { useStorage } from '@vueuse/core'
import { tokens } from '../design/tokens'
const { min, max, step, basePercent } = tokens.fontScale
/**
* User-controlled text scaling. Persisted and applied to the root element so all
* rem-based sizing scales with it (WCAG 1.4.4).
*/
export function useA11yPrefs() {
const fontScale = useStorage<number>('tarninahub-font-scale', 1)
const clamped = computed(() => Math.min(max, Math.max(min, fontScale.value)))
watch(
clamped,
(value) => {
document.documentElement.style.fontSize = `${value * basePercent}%`
},
{ immediate: true },
)
function increase(): void {
fontScale.value = Math.min(max, Number((clamped.value + step).toFixed(2)))
}
function decrease(): void {
fontScale.value = Math.max(min, Number((clamped.value - step).toFixed(2)))
}
function reset(): void {
fontScale.value = 1
}
return {
fontScale: clamped,
canIncrease: computed(() => clamped.value < max),
canDecrease: computed(() => clamped.value > min),
increase,
decrease,
reset,
}
}
+14
View File
@@ -0,0 +1,14 @@
import { ref } from 'vue'
/**
* Global, single live region content. Consumed by `AppAnnouncer.vue`.
* Setting the same message twice is forced to re-announce by clearing first.
*/
export const announcement = ref('')
export function announce(message: string): void {
announcement.value = ''
window.setTimeout(() => {
announcement.value = message
}, 50)
}
+27
View File
@@ -0,0 +1,27 @@
:root {
--th-space-xs: 0.25rem;
--th-space-sm: 0.5rem;
--th-space-md: 1rem;
--th-space-lg: 1.5rem;
--th-space-xl: 2rem;
--th-space-2xl: 3rem;
--th-radius-sm: 6px;
--th-radius-md: 10px;
--th-radius-lg: 16px;
--th-radius-pill: 999px;
--th-touch-target: 44px;
}
/* Simple, accessible layout primitives (no dependency on utility framework). */
.th-stack > * + * {
margin-block-start: var(--th-space-md);
}
.th-cluster {
display: flex;
flex-wrap: wrap;
gap: var(--th-space-sm);
align-items: center;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Design tokens for TarninaHub (single source of truth in JS/TS).
* CSS custom properties live in `src/design/tokens.css` and must stay in sync.
*/
export const tokens = {
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
'2xl': '3rem',
},
radius: {
sm: '6px',
md: '10px',
lg: '16px',
pill: '999px',
},
/** Minimum interactive target size (WCAG 2.2 AA 24px; we aim for senior-friendly 44px). */
touchTarget: '44px',
fontScale: {
min: 1,
max: 1.5,
step: 0.25,
basePercent: 112.5,
},
} as const
export type DesignTokens = typeof tokens
+46 -2
View File
@@ -83,18 +83,62 @@ export const dark: ThemeDefinition = {
}, },
} }
export const highContrast: ThemeDefinition = {
dark: true,
colors: {
background: '#000000',
'on-background': '#FFFFFF',
surface: '#000000',
'on-surface': '#FFFFFF',
'surface-bright': '#000000',
'on-surface-bright': '#FFFFFF',
'surface-light': '#000000',
'on-surface-light': '#FFFFFF',
'surface-variant': '#FFFFFF',
'on-surface-variant': '#000000',
primary: '#FFFF00',
'on-primary': '#000000',
secondary: '#00FFFF',
'on-secondary': '#000000',
info: '#80D8FF',
'on-info': '#000000',
success: '#69F0AE',
'on-success': '#000000',
warning: '#FFD740',
'on-warning': '#000000',
error: '#FF8A80',
'on-error': '#000000',
outline: '#FFFFFF',
focus: '#FFFF00',
},
variables: {
'border-color': '#FFFFFF',
'border-opacity': 1,
'high-emphasis-opacity': 1,
'medium-emphasis-opacity': 1,
'disabled-opacity': 0.6,
},
}
export default createVuetify({ export default createVuetify({
components, components,
directives, directives,
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } }, locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
theme: { defaultTheme: 'system', themes: { light, dark } }, theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
defaults: { defaults: {
global: { ripple: false }, global: { ripple: false },
VBtn: { size: 'large', variant: 'flat', color: 'primary' }, VBtn: { size: 'large', variant: 'flat', color: 'primary' },
VTextField: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, VTextField: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' },
VTextarea: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, VTextarea: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' },
VSelect: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, VSelect: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' },
VAutocomplete: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, VAutocomplete: {
variant: 'outlined',
density: 'default',
color: 'primary',
hideDetails: 'auto',
},
VCheckbox: { color: 'primary', density: 'default' }, VCheckbox: { color: 'primary', density: 'default' },
VRadioGroup: { color: 'primary' }, VRadioGroup: { color: 'primary' },
VSwitch: { color: 'primary', inset: true }, VSwitch: { color: 'primary', inset: true },
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, request, setAuthTokenProvider } from './index'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
describe('api client', () => {
afterEach(() => {
vi.restoreAllMocks()
setAuthTokenProvider(() => null)
})
it('returns parsed JSON and attaches the bearer token', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: 'ok' }))
vi.stubGlobal('fetch', fetchMock)
setAuthTokenProvider(() => 'token-123')
const result = await request<{ status: string }>('/api/health')
expect(result.status).toBe('ok')
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
expect(init.headers).toMatchObject({ Authorization: 'Bearer token-123' })
})
it('throws ApiError carrying ProblemDetails', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
jsonResponse({ title: 'Bad request', status: 400, errors: { field: ['nope'] } }, 400),
),
)
const error = await request('/api/x').catch((value: unknown) => value)
expect(error).toBeInstanceOf(ApiError)
expect((error as ApiError).status).toBe(400)
expect((error as ApiError).validationMessages).toEqual(['nope'])
})
})
+72
View File
@@ -0,0 +1,72 @@
import { ApiError, type ProblemDetails } from './errors'
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
let tokenProvider: () => string | null = () => null
/** Wires the API client to the session store without creating a hard dependency. */
export function setAuthTokenProvider(provider: () => string | null): void {
tokenProvider = provider
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown
headers?: Record<string, string>
signal?: AbortSignal
/** Attach the bearer token when available (default: true). */
auth?: boolean
}
async function parseBody(response: Response): Promise<unknown> {
if (response.status === 204 || response.headers.get('content-length') === '0') {
return undefined
}
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return undefined
}
try {
return await response.json()
} catch {
return undefined
}
}
/**
* Minimal typed fetch wrapper: JSON in/out, bearer auth, RFC 7807 error mapping.
* Throws `ApiError` on non-2xx responses.
*/
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {}, signal, auth = true } = options
const requestHeaders: Record<string, string> = { Accept: 'application/json', ...headers }
const token = auth ? tokenProvider() : null
if (token) {
requestHeaders.Authorization = `Bearer ${token}`
}
if (body !== undefined) {
requestHeaders['Content-Type'] = 'application/json'
}
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: requestHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
})
const payload = await parseBody(response)
if (!response.ok) {
const problem = (payload as ProblemDetails | undefined) ?? null
throw new ApiError(response.status, problem)
}
return payload as T
}
+32
View File
@@ -0,0 +1,32 @@
export interface ProblemDetails {
type?: string
title?: string
status?: number
detail?: string
errors?: Record<string, string[]>
}
export class ApiError extends Error {
readonly status: number
readonly problem: ProblemDetails | null
constructor(status: number, problem: ProblemDetails | null, message?: string) {
super(message ?? problem?.title ?? `Request failed with status ${status}`)
this.name = 'ApiError'
this.status = status
this.problem = problem
}
/** Flattened validation messages (ASP.NET ProblemDetails `errors`). */
get validationMessages(): string[] {
if (!this.problem?.errors) {
return []
}
return Object.values(this.problem.errors).flat()
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}
+9
View File
@@ -0,0 +1,9 @@
import { request } from './client'
export interface HealthResponse {
status: string
}
export function getHealth(signal?: AbortSignal): Promise<HealthResponse> {
return request<HealthResponse>('/api/health', { signal, auth: false })
}
+3
View File
@@ -0,0 +1,3 @@
export { request, setAuthTokenProvider, type RequestOptions } from './client'
export { ApiError, isApiError, type ProblemDetails } from './errors'
export { getHealth, type HealthResponse } from './health'
+18 -2
View File
@@ -4,6 +4,9 @@
.v-theme--dark { .v-theme--dark {
--a11y-focus: #ffd54f; --a11y-focus: #ffd54f;
} }
.v-theme--highContrast {
--a11y-focus: #ffff00;
}
html { html {
font-size: 112.5%; font-size: 112.5%;
@@ -13,8 +16,21 @@ body {
line-height: 1.6; line-height: 1.6;
} }
:where(a, button, input, select, textarea, summary, [tabindex], .v-btn, .v-field, .v-list-item, :where(
.v-tab, .v-chip--link, .v-selection-control__input):focus-visible { a,
button,
input,
select,
textarea,
summary,
[tabindex],
.v-btn,
.v-field,
.v-list-item,
.v-tab,
.v-chip--link,
.v-selection-control__input
):focus-visible {
outline: 3px solid var(--a11y-focus) !important; outline: 3px solid var(--a11y-focus) !important;
outline-offset: 2px; outline-offset: 2px;
} }