feat(ui): error container

This commit is contained in:
Neko Ayaka
2026-04-10 03:40:09 +08:00
parent 4da2ab8924
commit ec4d1998d7
6 changed files with 298 additions and 3 deletions
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ContainerError } from '@proj-airi/ui'
import { ref } from 'vue'
const actionLog = ref('No action yet')
const sampleError = new Error('Request failed with status code 500')
function noteCopy(content: string) {
actionLog.value = `Copied ${content.length} characters`
}
function noteFeedback() {
actionLog.value = 'Feedback action clicked'
}
</script>
<template>
<Story
title="Error Message Panel"
group="misc"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="message-only"
title="Message Only"
>
<div class="max-w-3xl">
<ContainerError message="Unable to load project settings. Please try again." />
</div>
</Variant>
<Variant
id="with-stack"
title="With Stack Trace"
>
<div class="max-w-3xl">
<ContainerError :error="sampleError" height-preset="lg" />
</div>
</Variant>
<Variant
id="actions"
title="Action Callbacks"
>
<div class="max-w-3xl flex flex-col gap-3">
<ContainerError :error="sampleError" @copy="noteCopy" @feedback="noteFeedback" />
<div class="text-sm text-neutral-600 dark:text-neutral-300">
{{ actionLog }}
</div>
</div>
</Variant>
</Story>
</template>
+1
View File
@@ -26,6 +26,7 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@moeru/std": "catalog:",
"@vueuse/core": "^14.2.1",
"floating-vue": "^5.2.2",
"reka-ui": "^2.9.2",
+16 -3
View File
@@ -19,6 +19,7 @@ interface ButtonProps {
loading?: boolean // Loading state
variant?: ButtonVariant // Button style variant
size?: ButtonSize // Button size variant
shape?: 'rounded' | 'pill' | 'square' // Button shape
theme?: ButtonTheme // Button theme
block?: boolean // Full width button
}
@@ -127,9 +128,21 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
// Extract size styles for better organization
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
sm: props.shape === 'pill'
? 'px-3 py-1.5 text-xs'
: props.shape === 'square'
? 'p-2 text-xs'
: 'px-4 py-2 text-sm',
md: props.shape === 'pill'
? 'px-4 py-2 text-sm'
: props.shape === 'square'
? 'p-3 text-sm'
: 'px-5 py-3 text-base',
lg: props.shape === 'pill'
? 'px-6 py-3 text-base'
: props.shape === 'square'
? 'p-4 text-base'
: 'px-6 py-3 text-base',
}
// Base classes that are always applied
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { errorCauseFrom, errorMessageFrom, errorNameFrom, errorStackFrom } from '@moeru/std'
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, shallowRef } from 'vue'
import Button from './button.vue'
type HeightPreset = 'sm' | 'md' | 'lg' | 'xl' | 'auto'
interface ContainerErrorProps {
error?: unknown
message?: string
stack?: string
includeStack?: boolean
showCopyButton?: boolean
showFeedbackButton?: boolean
copyButtonLabel?: string
copiedButtonLabel?: string
feedbackButtonLabel?: string
heightPreset?: HeightPreset
}
const props = withDefaults(defineProps<ContainerErrorProps>(), {
includeStack: true,
showCopyButton: true,
showFeedbackButton: true,
copyButtonLabel: 'Copy',
copiedButtonLabel: 'Copied',
feedbackButtonLabel: 'Feedback',
heightPreset: 'md',
})
const emit = defineEmits<{
(e: 'copy', content: string): void
(e: 'feedback'): void
}>()
const copied = shallowRef(false)
const heightPresetClasses: Record<HeightPreset, string[]> = {
sm: ['h-32'],
md: ['h-36'],
lg: ['h-42'],
xl: ['h-48'],
auto: [],
}
function indentLines(content: string, indent = 2): string {
const spaces = ' '.repeat(indent)
return content
.split('\n')
.map(line => `${spaces}${line}`)
.join('\n')
}
const resolvedErrorName = computed(() => {
return (errorNameFrom(props.error) ?? '').trim()
})
const resolvedMessage = computed(() => {
const fromProp = props.message?.trim()
if (fromProp)
return fromProp
const normalizedMessage = errorMessageFrom(props.error)
if (normalizedMessage)
return normalizedMessage.trim()
return props.error == null ? '' : String(props.error).trim()
})
const resolvedStack = computed(() => {
const fromProp = props.stack?.trim()
if (fromProp) {
const index = fromProp.indexOf(resolvedMessage.value)
if (index >= 0)
return fromProp.slice(index + resolvedMessage.value.length).trim()
}
if (!props.includeStack)
return ''
const resolved = (errorStackFrom(props.error) ?? '').trim()
const index = resolved.indexOf(resolvedMessage.value)
if (index >= 0)
return resolved.slice(index + resolvedMessage.value.length).trim()
return resolved
})
const resolvedCause = computed(() => {
if (props.error == null)
return ''
const cause = errorCauseFrom(props.error)
if (cause == null)
return ''
if (cause instanceof Error) {
const name = errorNameFrom(cause) ?? cause.name ?? 'Error'
const message = (errorMessageFrom(cause) ?? cause.message ?? '').trim()
const stack = (errorStackFrom(cause) ?? cause.stack ?? '').trim()
const header = message ? `${name}: ${message}` : name
if (!stack)
return header
return `${header}\n${indentLines(stack, 2)}`
}
return String(cause).trim()
})
const panelContent = computed(() => {
const sections: string[] = []
if (resolvedErrorName.value || resolvedMessage.value) {
const header = resolvedErrorName.value
? (resolvedMessage.value ? `${resolvedErrorName.value}: ${resolvedMessage.value}` : resolvedErrorName.value)
: resolvedMessage.value
if (header)
sections.push(header)
}
if (resolvedStack.value)
sections.push(`Stack:\n${resolvedStack.value}`)
if (resolvedCause.value)
sections.push(`Cause:\n${resolvedCause.value}`)
return sections.join('\n\n')
})
async function copyContent() {
if (!panelContent.value)
return
emit('copy', panelContent.value)
try {
const clipboard = globalThis.navigator?.clipboard
if (!clipboard)
return
await clipboard.writeText(panelContent.value)
copied.value = true
globalThis.setTimeout(() => {
copied.value = false
}, 1200)
}
catch {
// Ignore clipboard failures and still keep emitted copy payload.
}
}
</script>
<template>
<div
:class="[
'relative w-full rounded-xl border-2 border-red-200/70 bg-red-50/60 backdrop-blur-md p-1',
'dark:border-red-900/50 dark:bg-red-950/25',
]"
>
<div :class="['absolute right-4 -translate-x-full top-2 z-10']">
<Button
v-if="showCopyButton"
size="sm"
variant="secondary"
shape="square"
icon="i-solar:copy-line-duotone"
:title="copied ? copiedButtonLabel : copyButtonLabel"
:aria-label="copied ? copiedButtonLabel : copyButtonLabel"
@click="copyContent"
/>
</div>
<div :class="['absolute right-2 top-2 z-10']">
<Button
v-if="showFeedbackButton"
size="sm"
variant="secondary"
shape="square"
icon="i-solar:square-share-line-line-duotone"
:title="feedbackButtonLabel"
:aria-label="feedbackButtonLabel"
@click="emit('feedback')"
/>
</div>
<ScrollAreaRoot
type="auto"
:class="[
'relative w-full overflow-hidden rounded-xl',
'bg-neutral-50/80 dark:bg-neutral-950/80',
...heightPresetClasses[heightPreset],
]"
>
<ScrollAreaViewport :class="['h-full w-full']">
<div :class="['flex flex-col gap-2 p-3 text-xs']">
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono font-semibold text-red-700 leading-relaxed dark:text-red-300']">
{{ resolvedErrorName || 'Error' }}
<span v-if="resolvedMessage">
: {{ resolvedMessage }}
</span>
</div>
<pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
<pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
<div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
No error details available.
</div>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" :class="['w-2 p-0.5']">
<ScrollAreaThumb :class="['rounded-full bg-neutral-300/80 dark:bg-neutral-700/80']" />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
</div>
</template>
+1
View File
@@ -1,4 +1,5 @@
export { default as Button } from './button.vue'
export { default as Callout } from './callout.vue'
export { default as ContainerError } from './container-error.vue'
export { default as DoubleCheckButton } from './double-check-button.vue'
export { default as Progress } from './progress.vue'
+3
View File
@@ -3481,6 +3481,9 @@ importers:
packages/ui:
dependencies:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@vueuse/core':
specifier: ^14.2.1
version: 14.2.1(vue@3.5.30(typescript@5.9.3))