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({
|
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 },
|
||||||
|
|||||||
@@ -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 {
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user