feat(web): 完善共享动作与反馈组件
This commit is contained in:
16 files changed
+428
-16
No files matched your search
@@ -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,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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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']),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user