feat(gui-v2): StateBlock 3-state wrapper (exhaustive Vitest, no @visual per constraint #5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:00:20 +02:00
parent b0d5e9611f
commit 284fdcc437
3 changed files with 167 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StateBlock from '@/components-v2/shared/StateBlock.vue'
/**
* StateBlock stories. NOTE: per Plan 3 constraint #5 this component has
* NO Tier-4 @visual baseline yet (self-baseline is tautological). These
* stories exist for autodocs + a11y + manual review only.
*/
const meta: Meta<typeof StateBlock> = {
title: 'Shared/StateBlock',
component: StateBlock,
tags: ['autodocs'],
argTypes: { state: { control: 'inline-radio', options: ['loading', 'error', 'empty', 'success'] } },
}
export default meta
type Story = StoryObj<typeof StateBlock>
export const Loading: Story = { args: { state: 'loading' } }
export const Error: Story = { args: { state: 'error', errorMessage: 'Kon evenementen niet laden.' } }
export const Empty: Story = { args: { state: 'empty', emptyMessage: 'Nog geen evenementen.', actionLabel: 'Nieuw evenement' } }
export const Success: Story = {
args: { state: 'success' },
render: args => ({ components: { StateBlock }, setup: () => ({ args }), template: '<StateBlock v-bind="args"><p>Echte inhoud.</p></StateBlock>' }),
}

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* StateBlock — the CLAUDE.md mandatory three-state wrapper (loading /
* error / empty) + success passthrough. Built fresh (no crewli-starter
* source). Per Plan 3 constraint #5 this component intentionally has
* NO @visual baseline yet (self-baseline is tautological); correctness
* is locked by the exhaustive Vitest spec. Visual baseline is a
* Plans 45 follow-up after first real page usage.
*/
import Skeleton from 'primevue/skeleton'
import Message from 'primevue/message'
import Button from 'primevue/button'
import Card from 'primevue/card'
defineProps<{
state: 'loading' | 'error' | 'empty' | 'success'
errorMessage?: string
emptyMessage?: string
actionLabel?: string
retryLabel?: string
}>()
const emit = defineEmits<{ retry: []; action: [] }>()
</script>
<template>
<div
v-if="state === 'loading'"
class="flex flex-col gap-3"
data-state="loading"
>
<Skeleton height="2rem" />
<Skeleton
height="2rem"
width="80%"
/>
<Skeleton
height="2rem"
width="60%"
/>
</div>
<Message
v-else-if="state === 'error'"
severity="error"
:closable="false"
data-state="error"
>
<div class="flex items-center justify-between gap-4 w-full">
<span>{{ errorMessage ?? 'Er ging iets mis.' }}</span>
<Button
:label="retryLabel ?? 'Opnieuw proberen'"
size="small"
@click="emit('retry')"
/>
</div>
</Message>
<Card
v-else-if="state === 'empty'"
data-state="empty"
:pt="{ root: 'border border-dashed border-[var(--p-content-border-color)] shadow-none', content: 'flex flex-col items-center gap-3 py-10 text-center' }"
>
<template #content>
<p class="m-0 text-[var(--p-text-muted-color)]">
{{ emptyMessage ?? 'Nog niets om te tonen.' }}
</p>
<Button
v-if="actionLabel"
:label="actionLabel"
@click="emit('action')"
/>
</template>
</Card>
<slot v-else />
</template>

View File

@@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import StateBlock from '@/components-v2/shared/StateBlock.vue'
const stubs = {
Skeleton: defineComponent({ name: 'Skeleton', template: '<div class="skeleton-stub" />' }),
Message: defineComponent({ name: 'Message', props: ['severity'], template: '<div class="message-stub"><slot /></div>' }),
Button: defineComponent({ name: 'Button', props: ['label'], emits: ['click'], template: '<button class="btn-stub" @click="$emit(\'click\')">{{ label }}</button>' }),
Card: defineComponent({ name: 'Card', template: '<div class="card-stub"><slot name="content" /></div>' }),
}
const mountSB = (props: { state: 'loading' | 'error' | 'empty' | 'success'; errorMessage?: string; emptyMessage?: string; actionLabel?: string; retryLabel?: string }, slots: Record<string, string> = {}) =>
mount(StateBlock, { props, slots, global: { stubs } })
describe('StateBlock', () => {
it('loading: renders Skeleton, nothing else', () => {
const w = mountSB({ state: 'loading' }, { default: '<p>data</p>' })
expect(w.find('.skeleton-stub').exists()).toBe(true)
expect(w.text()).not.toContain('data')
})
it('error: renders Message + retry Button; emits retry on click', async () => {
const w = mountSB({ state: 'error', errorMessage: 'Mislukt' })
expect(w.get('.message-stub').text()).toContain('Mislukt')
await w.get('.btn-stub').trigger('click')
expect(w.emitted('retry')).toHaveLength(1)
})
it('empty: renders empty Card + action Button; emits action on click', async () => {
const w = mountSB({ state: 'empty', emptyMessage: 'Niets hier', actionLabel: 'Maak aan' })
expect(w.text()).toContain('Niets hier')
await w.get('.btn-stub').trigger('click')
expect(w.emitted('action')).toHaveLength(1)
})
it('success: renders the default slot, no state chrome', () => {
const w = mountSB({ state: 'success' }, { default: '<p>real content</p>' })
expect(w.text()).toContain('real content')
expect(w.find('.skeleton-stub').exists()).toBe(false)
expect(w.find('.message-stub').exists()).toBe(false)
})
it('transition loading→success swaps chrome for slot content', async () => {
const w = mountSB({ state: 'loading' }, { default: '<p>loaded</p>' })
expect(w.find('.skeleton-stub').exists()).toBe(true)
await w.setProps({ state: 'success' })
expect(w.find('.skeleton-stub').exists()).toBe(false)
expect(w.text()).toContain('loaded')
})
it('transition error→loading clears the message', async () => {
const w = mountSB({ state: 'error', errorMessage: 'Mislukt' })
expect(w.text()).toContain('Mislukt')
await w.setProps({ state: 'loading' })
expect(w.find('.message-stub').exists()).toBe(false)
expect(w.find('.skeleton-stub').exists()).toBe(true)
})
})