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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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({
|
||||
components,
|
||||
directives,
|
||||
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
|
||||
theme: { defaultTheme: 'system', themes: { light, dark } },
|
||||
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
|
||||
defaults: {
|
||||
global: { ripple: false },
|
||||
VBtn: { size: 'large', variant: 'flat', color: 'primary' },
|
||||
VTextField: { 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' },
|
||||
VAutocomplete: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' },
|
||||
VAutocomplete: {
|
||||
variant: 'outlined',
|
||||
density: 'default',
|
||||
color: 'primary',
|
||||
hideDetails: 'auto',
|
||||
},
|
||||
VCheckbox: { color: 'primary', density: 'default' },
|
||||
VRadioGroup: { color: 'primary' },
|
||||
VSwitch: { color: 'primary', inset: true },
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { request, setAuthTokenProvider, type RequestOptions } from './client'
|
||||
export { ApiError, isApiError, type ProblemDetails } from './errors'
|
||||
export { getHealth, type HealthResponse } from './health'
|
||||
@@ -4,6 +4,9 @@
|
||||
.v-theme--dark {
|
||||
--a11y-focus: #ffd54f;
|
||||
}
|
||||
.v-theme--highContrast {
|
||||
--a11y-focus: #ffff00;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 112.5%;
|
||||
@@ -13,8 +16,21 @@ body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:where(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 {
|
||||
:where(
|
||||
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-offset: 2px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user