feat(ui): error container
This commit is contained in:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
Generated
+3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user