feat(web): 完善共享动作与反馈组件

This commit is contained in:
tbphp committed 2026-07-29 01:51:45 +08:00
1 parent 18406de18a
commit 83b2c8a45d
16 files changed
+428 -16

No files matched your search

+17
View File
@@ -39,4 +39,21 @@ describe('AppButton', () => {
await button.trigger('click')
expect(onClick).not.toHaveBeenCalled()
})
it('exposes danger and size as semantic classes', () => {
const wrapper = mount(AppButton, {
props: {
variant: 'danger',
size: 'lg',
},
slots: {
default: 'Delete permanently',
},
})
expect(wrapper.get('button').classes()).toEqual(
expect.arrayContaining(['app-button--danger', 'app-button--lg']),
)
expect(wrapper.text()).toBe('Delete permanently')
})
})
+4 -2
View File
@@ -4,13 +4,15 @@ withDefaults(
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
busy?: boolean
variant?: 'primary' | 'secondary' | 'ghost'
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
}>(),
{
type: 'button',
disabled: false,
busy: false,
variant: 'primary',
size: 'md',
},
)
</script>
@@ -18,7 +20,7 @@ withDefaults(
<template>
<button
class="app-button"
:class="`app-button--${variant}`"
:class="[`app-button--${variant}`, `app-button--${size}`]"
:type="type"
:disabled="disabled || busy"
:aria-busy="busy ? 'true' : undefined"
+13 -3
View File
@@ -1,11 +1,20 @@
<script setup lang="ts">
defineProps<{ title: string; description: string }>()
withDefaults(
defineProps<{
title: string
description: string
headingAs?: 'h2' | 'h3' | 'h4' | 'p'
}>(),
{
headingAs: 'h2',
},
)
</script>
<template>
<div class="empty-state">
<div class="empty-state__icon" aria-hidden="true"><slot name="icon" /></div>
<h2>{{ title }}</h2>
<component :is="headingAs" class="empty-state__title">{{ title }}</component>
<p>{{ description }}</p>
<div v-if="$slots.actions" class="empty-state__actions"><slot name="actions" /></div>
</div>
@@ -26,9 +35,10 @@ defineProps<{ title: string; description: string }>()
.empty-state__icon {
color: var(--color-text-faint);
}
.empty-state h2 {
.empty-state__title {
margin: var(--space-3) 0 0;
font-size: 1.125rem;
font-weight: 650;
}
.empty-state p {
max-width: 54ch;
+38
View File
@@ -29,4 +29,42 @@ describe('FormField', () => {
)
expect(wrapper.get('#auth-key-error [aria-hidden="true"]').text()).toBe('▲')
})
it('exposes required, invalid and disabled-reason metadata to the control slot', () => {
const wrapper = mount(FormField, {
props: {
id: 'group-name',
label: 'Name',
required: true,
requiredText: 'Required',
error: 'Name is required.',
disabledReason: 'Wait for discovery to finish.',
},
slots: {
default: ({
describedBy,
invalid,
required,
}: {
describedBy?: string
invalid: boolean
required: boolean
}) =>
h('input', {
id: 'group-name',
'aria-describedby': describedBy,
'aria-invalid': invalid,
required,
}),
},
})
expect(wrapper.get('label').text()).toContain('Required')
expect(wrapper.get('input').attributes('required')).toBe('')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('input').attributes('aria-describedby')).toBe(
'group-name-disabled-reason group-name-error',
)
expect(wrapper.get('#group-name-disabled-reason').text()).toBe('Wait for discovery to finish.')
})
})
+31 -3
View File
@@ -6,22 +6,50 @@ const props = defineProps<{
label: string
description?: string
error?: string
required?: boolean
requiredText?: string
disabledReason?: string
}>()
const descriptionId = computed(() => (props.description ? `${props.id}-description` : undefined))
const errorId = computed(() => (props.error ? `${props.id}-error` : undefined))
const disabledReasonId = computed(() =>
props.disabledReason ? `${props.id}-disabled-reason` : undefined,
)
const describedBy = computed(
() => [descriptionId.value, errorId.value].filter(Boolean).join(' ') || undefined,
() =>
[descriptionId.value, disabledReasonId.value, errorId.value].filter(Boolean).join(' ') ||
undefined,
)
</script>
<template>
<div class="form-field">
<label class="form-field__label" :for="id">{{ label }}</label>
<slot :described-by="describedBy" :description-id="descriptionId" :error-id="errorId" />
<label class="form-field__label" :for="id">
{{ label }}
<template v-if="required">
<span aria-hidden="true">*</span>
<span v-if="requiredText" class="sr-only">{{ requiredText }}</span>
</template>
</label>
<slot
:described-by="describedBy"
:description-id="descriptionId"
:disabled-reason-id="disabledReasonId"
:error-id="errorId"
:invalid="Boolean(error)"
:required="Boolean(required)"
/>
<p v-if="description" :id="descriptionId" class="form-field__description">
{{ description }}
</p>
<p
v-if="disabledReason"
:id="disabledReasonId"
class="form-field__description form-field__disabled-reason"
>
{{ disabledReason }}
</p>
<p v-if="error" :id="errorId" class="form-field__error" role="alert">
<span aria-hidden="true">▲</span>
<span>{{ error }}</span>
+15 -2
View File
@@ -3,19 +3,23 @@ withDefaults(
defineProps<{
label: string
disabled?: boolean
busy?: boolean
pressed?: boolean
variant?: 'default' | 'danger'
}>(),
{ disabled: false, pressed: undefined },
{ disabled: false, busy: false, pressed: undefined, variant: 'default' },
)
</script>
<template>
<button
class="icon-button"
:class="`icon-button--${variant}`"
type="button"
:aria-label="label"
:aria-pressed="pressed"
:disabled="disabled"
:aria-busy="busy ? 'true' : undefined"
:disabled="disabled || busy"
>
<slot />
</button>
@@ -47,4 +51,13 @@ withDefaults(
cursor: not-allowed;
opacity: 0.6;
}
.icon-button--danger {
border-color: var(--color-danger);
color: var(--color-danger);
}
.icon-button[aria-busy='true'] {
cursor: wait;
}
</style>
@@ -0,0 +1,36 @@
import { mount } from '@vue/test-utils'
import OperationNotice from './OperationNotice.vue'
describe('OperationNotice', () => {
it('announces an indeterminate operation and emits its named recovery action', async () => {
const wrapper = mount(OperationNotice, {
props: {
state: 'indeterminate',
message: 'The server result is not confirmed.',
actionLabel: 'Check result',
},
})
expect(wrapper.attributes('role')).toBe('status')
expect(wrapper.attributes('aria-live')).toBe('polite')
expect(wrapper.text()).toContain('The server result is not confirmed.')
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('recover')).toHaveLength(1)
})
it('disables recovery while reconciliation is running', () => {
const wrapper = mount(OperationNotice, {
props: {
state: 'reconciling',
message: 'Checking the server result.',
actionLabel: 'Check result',
busy: true,
},
})
expect(wrapper.get('button').attributes()).toHaveProperty('disabled')
expect(wrapper.get('button').attributes('aria-busy')).toBe('true')
})
})
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { CircleAlert, LoaderCircle } from 'lucide-vue-next'
import AppButton from './AppButton.vue'
withDefaults(
defineProps<{
state: 'indeterminate' | 'reconciling'
message: string
actionLabel: string
busy?: boolean
}>(),
{
busy: false,
},
)
defineEmits<{ recover: [] }>()
</script>
<template>
<div
class="operation-notice"
:class="`operation-notice--${state}`"
role="status"
aria-live="polite"
>
<LoaderCircle v-if="state === 'reconciling'" class="operation-notice__spin" :size="18" />
<CircleAlert v-else :size="18" />
<span class="operation-notice__message">{{ message }}</span>
<AppButton
variant="secondary"
size="sm"
:busy="busy"
:disabled="busy"
@click="$emit('recover')"
>
{{ actionLabel }}
</AppButton>
</div>
</template>
<style scoped>
.operation-notice {
display: flex;
min-height: var(--touch-target);
align-items: center;
gap: var(--space-2);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-action-soft);
color: var(--color-text);
padding: var(--space-2) var(--space-3);
}
.operation-notice__message {
flex: 1;
}
.operation-notice__spin {
animation: operation-spin 1s linear infinite;
}
@keyframes operation-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 639px) {
.operation-notice {
align-items: flex-start;
flex-wrap: wrap;
}
.operation-notice__message {
min-width: calc(100% - 32px);
}
}
@media (prefers-reduced-motion: reduce) {
.operation-notice__spin {
animation: none;
}
}
</style>
+7 -2
View File
@@ -3,7 +3,7 @@ import { LoaderCircle, RefreshCw, TriangleAlert } from 'lucide-vue-next'
withDefaults(
defineProps<{
state: 'loading' | 'error' | 'stale'
state: 'loading' | 'error' | 'stale' | 'indeterminate'
message: string
retryLabel?: string
}>(),
@@ -16,7 +16,7 @@ defineEmits<{ retry: [] }>()
<div
class="query-feedback"
:class="`query-feedback--${state}`"
:role="state === 'loading' ? 'status' : 'alert'"
:role="state === 'loading' || state === 'indeterminate' ? 'status' : 'alert'"
>
<LoaderCircle v-if="state === 'loading'" class="query-feedback__spin" :size="18" />
<TriangleAlert v-else :size="18" />
@@ -44,6 +44,11 @@ defineEmits<{ retry: [] }>()
background: var(--color-danger-bg);
color: var(--color-danger);
}
.query-feedback--indeterminate {
border-color: var(--color-border-control);
background: var(--color-action-soft);
color: var(--color-text);
}
.query-feedback button {
display: inline-flex;
min-height: 44px;
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
withDefaults(
defineProps<{
height?: string
rounded?: boolean
}>(),
{
height: '1rem',
rounded: true,
},
)
</script>
<template>
<span
class="skeleton-block"
:class="{ 'skeleton-block--rounded': rounded }"
:style="{ '--skeleton-height': height }"
aria-hidden="true"
/>
</template>
<style scoped>
.skeleton-block {
display: block;
width: 100%;
height: var(--skeleton-height);
background: linear-gradient(
90deg,
var(--color-surface-sunken),
var(--color-border-subtle),
var(--color-surface-sunken)
);
background-size: 200% 100%;
animation: skeleton-shift 1.4s var(--easing-standard) infinite;
}
.skeleton-block--rounded {
border-radius: var(--radius-control);
}
@keyframes skeleton-shift {
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton-block {
animation: none;
}
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { mount } from '@vue/test-utils'
import Surface from './Surface.vue'
describe('Surface', () => {
it('renders the selected semantic element and visual variant', () => {
const wrapper = mount(Surface, {
props: {
as: 'aside',
variant: 'sunken',
padded: false,
},
slots: {
default: 'Recovery details',
},
})
expect(wrapper.element.tagName).toBe('ASIDE')
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['surface', 'surface--sunken', 'surface--flush']),
)
})
})
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
withDefaults(
defineProps<{
as?: 'article' | 'aside' | 'div' | 'section'
variant?: 'default' | 'raised' | 'sunken'
padded?: boolean
}>(),
{
as: 'div',
variant: 'default',
padded: true,
},
)
</script>
<template>
<component
:is="as"
class="surface"
:class="[`surface--${variant}`, { 'surface--flush': !padded }]"
>
<slot />
</component>
</template>
<style scoped>
.surface {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-card);
background: var(--color-surface);
padding: var(--space-6);
}
.surface--raised {
background: var(--color-surface-raised);
box-shadow: var(--shadow-card);
}
.surface--sunken {
background: var(--color-surface-sunken);
}
.surface--flush {
padding: 0;
}
</style>
+6 -2
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
import Surface from './Surface.vue'
</script>
<template>
<section class="surface-card">
<Surface as="section" variant="raised" class="surface-card">
<slot />
</section>
</Surface>
</template>
@@ -16,7 +16,7 @@ describe('shared accessibility styles', () => {
it('keeps keyboard focus visible and long dialogs within the viewport', () => {
expect(baseStyles).toMatch(
/:focus-visible\s*\{[\s\S]*outline: 2px solid var\(--color-primary\);[\s\S]*outline-offset: 2px;/,
/:focus-visible\s*\{[\s\S]*outline: 2px solid var\(--color-focus\);[\s\S]*outline-offset: 2px;/,
)
expect(appDialog).toMatch(/max-height: calc\(100dvh - 32px\)/)
expect(appDialog).toMatch(/\.app-dialog__body\s*\{[\s\S]*overflow-y: auto/)
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import EmptyState from './EmptyState.vue'
import QueryFeedback from './QueryFeedback.vue'
describe('feedback contracts', () => {
it('renders indeterminate query state with an explicit recovery action', async () => {
const wrapper = mount(QueryFeedback, {
props: {
state: 'indeterminate',
message: 'Result not confirmed.',
retryLabel: 'Check result',
},
})
expect(wrapper.attributes('role')).toBe('status')
expect(wrapper.text()).toContain('Result not confirmed.')
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
it('lets the owner select the empty-state heading level', () => {
const wrapper = mount(EmptyState, {
props: {
title: 'No matching entries',
description: 'Change the filters and try again.',
headingAs: 'h3',
},
})
expect(wrapper.get('h3').text()).toBe('No matching entries')
expect(wrapper.find('h2').exists()).toBe(false)
})
})
+18 -1
View File
@@ -294,7 +294,7 @@ h1 {
.app-button {
display: inline-flex;
min-height: 44px;
min-height: var(--control-md);
align-items: center;
justify-content: center;
border: 1px solid transparent;
@@ -310,6 +310,17 @@ h1 {
opacity var(--duration-fast) ease;
}
.app-button--sm {
min-height: var(--control-sm);
padding: 6px 12px;
font-size: var(--text-sm);
}
.app-button--lg {
min-height: var(--control-lg);
padding: 11px 18px;
}
.app-button--primary {
background: var(--color-primary);
color: var(--color-primary-ink);
@@ -326,6 +337,12 @@ h1 {
color: var(--color-text-muted);
}
.app-button--danger {
border-color: var(--color-danger);
background: var(--color-danger);
color: var(--color-text-inverse);
}
.app-button:hover:not(:disabled) {
opacity: 0.9;
}