@proj-airi/ui 0.9.0-beta.6 → 0.9.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/form/field/field-input-file.vue +38 -0
- package/src/components/form/field/field-input.vue +3 -1
- package/src/components/form/field/field-select.vue +105 -0
- package/src/components/form/field/index.ts +3 -1
- package/src/components/form/input/index.ts +1 -0
- package/src/components/form/input/input-file-card.vue +56 -0
- package/src/components/form/input/input-file.vue +81 -41
- package/src/components/form/input/input.vue +3 -1
- package/src/components/form/range/round-range.vue +2 -3
- package/src/components/form/select/select.vue +1 -2
- package/src/components/misc/button.vue +16 -3
- package/src/components/misc/container-error.vue +219 -0
- package/src/components/misc/index.ts +1 -0
- /package/src/components/form/field/{field-combobox.vue → field-combobox-select.vue} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proj-airi/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.9.0-
|
|
4
|
+
"version": "0.9.0-rc.1",
|
|
5
5
|
"description": "A collection of UI components that used by Project AIRI",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Moeru AI Project AIRI Team",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"./*": "./*"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
+
"@moeru/std": "0.1.0-beta.17",
|
|
26
27
|
"@vueuse/core": "^14.2.1",
|
|
27
28
|
"floating-vue": "^5.2.2",
|
|
28
29
|
"reka-ui": "^2.9.2",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { InputFile } from '../input'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
label?: string
|
|
6
|
+
description?: string
|
|
7
|
+
accept?: string
|
|
8
|
+
multiple?: boolean
|
|
9
|
+
placeholder?: string
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const modelValue = defineModel<File[] | undefined>({ required: false })
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div class="max-w-full">
|
|
17
|
+
<label class="flex flex-col gap-4">
|
|
18
|
+
<div>
|
|
19
|
+
<div class="flex items-center gap-1 text-sm font-medium">
|
|
20
|
+
<slot name="label">
|
|
21
|
+
{{ props.label }}
|
|
22
|
+
</slot>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
|
25
|
+
<slot name="description">
|
|
26
|
+
{{ props.description }}
|
|
27
|
+
</slot>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<InputFile
|
|
31
|
+
v-model="modelValue"
|
|
32
|
+
:accept="props.accept"
|
|
33
|
+
:multiple="props.multiple"
|
|
34
|
+
:placeholder="props.placeholder"
|
|
35
|
+
/>
|
|
36
|
+
</label>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script
|
|
2
2
|
setup
|
|
3
3
|
lang="ts"
|
|
4
|
-
generic="InputType extends 'number' | string, T = InputType extends 'number' ? (number | undefined) : ((string | undefined))"
|
|
4
|
+
generic="InputType extends 'number' | InputTypeHTMLAttribute | string, T = InputType extends 'number' ? (number | undefined) : ((string | undefined))"
|
|
5
5
|
>
|
|
6
|
+
import type { InputTypeHTMLAttribute } from 'vue'
|
|
7
|
+
|
|
6
8
|
import { Input } from '../input'
|
|
7
9
|
|
|
8
10
|
const props = withDefaults(defineProps<{
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends AcceptableValue">
|
|
2
|
+
import type { AcceptableValue } from 'reka-ui'
|
|
3
|
+
|
|
4
|
+
import { Select } from '../select'
|
|
5
|
+
|
|
6
|
+
interface SelectOptionItem<T extends AcceptableValue> {
|
|
7
|
+
label: string
|
|
8
|
+
value: T
|
|
9
|
+
description?: string
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
icon?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SelectOptionGroupItem<T extends AcceptableValue> {
|
|
15
|
+
groupLabel?: string
|
|
16
|
+
children?: SelectOptionItem<T>[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
label: string
|
|
21
|
+
description?: string
|
|
22
|
+
options?: SelectOptionItem<T>[] | SelectOptionGroupItem<T>[]
|
|
23
|
+
placeholder?: string
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
layout?: 'horizontal' | 'vertical'
|
|
26
|
+
selectClass?: string | string[]
|
|
27
|
+
by?: string | ((a: T, b: T) => boolean)
|
|
28
|
+
contentMinWidth?: string | number
|
|
29
|
+
contentWidth?: string | number
|
|
30
|
+
shape?: 'rounded' | 'default'
|
|
31
|
+
variant?: 'blurry' | 'default'
|
|
32
|
+
}>(), {
|
|
33
|
+
layout: 'horizontal',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const modelValue = defineModel<T>({ required: false })
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<label :class="['flex', 'flex-col', 'gap-4']">
|
|
41
|
+
<div
|
|
42
|
+
:class="[
|
|
43
|
+
'items-center',
|
|
44
|
+
props.layout === 'horizontal' ? 'grid grid-cols-4 gap-2' : 'grid grid-rows-2 gap-2',
|
|
45
|
+
]"
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
:class="[
|
|
49
|
+
'w-full',
|
|
50
|
+
props.layout === 'horizontal' ? 'col-span-2' : 'row-span-2',
|
|
51
|
+
]"
|
|
52
|
+
>
|
|
53
|
+
<div :class="['flex', 'items-center', 'gap-1', 'break-words', 'text-sm', 'font-medium', 'text-left']">
|
|
54
|
+
<slot name="label">
|
|
55
|
+
{{ props.label }}
|
|
56
|
+
</slot>
|
|
57
|
+
</div>
|
|
58
|
+
<div :class="['break-words', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400', 'text-left']">
|
|
59
|
+
<slot name="description">
|
|
60
|
+
{{ props.description }}
|
|
61
|
+
</slot>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<slot>
|
|
65
|
+
<Select
|
|
66
|
+
v-model="modelValue"
|
|
67
|
+
:options="props.options ?? []"
|
|
68
|
+
:placeholder="props.placeholder"
|
|
69
|
+
:disabled="props.disabled"
|
|
70
|
+
:by="props.by"
|
|
71
|
+
:content-min-width="props.contentMinWidth"
|
|
72
|
+
:content-width="props.contentWidth"
|
|
73
|
+
:shape="props.shape"
|
|
74
|
+
:variant="props.variant"
|
|
75
|
+
:class="[
|
|
76
|
+
...(props.selectClass
|
|
77
|
+
? (typeof props.selectClass === 'string' ? [props.selectClass] : props.selectClass)
|
|
78
|
+
: []),
|
|
79
|
+
props.layout === 'horizontal' ? 'col-span-2' : 'row-span-2',
|
|
80
|
+
]"
|
|
81
|
+
>
|
|
82
|
+
<template
|
|
83
|
+
v-if="$slots.value"
|
|
84
|
+
#value="{ option, value, placeholder: slotPlaceholder }"
|
|
85
|
+
>
|
|
86
|
+
<slot
|
|
87
|
+
name="value"
|
|
88
|
+
v-bind="{ option, value, placeholder: slotPlaceholder }"
|
|
89
|
+
/>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<template
|
|
93
|
+
v-if="$slots.option"
|
|
94
|
+
#option="{ option }"
|
|
95
|
+
>
|
|
96
|
+
<slot
|
|
97
|
+
name="option"
|
|
98
|
+
v-bind="{ option }"
|
|
99
|
+
/>
|
|
100
|
+
</template>
|
|
101
|
+
</Select>
|
|
102
|
+
</slot>
|
|
103
|
+
</div>
|
|
104
|
+
</label>
|
|
105
|
+
</template>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export { default as FieldCheckbox } from './field-checkbox.vue'
|
|
2
|
-
export { default as FieldCombobox } from './field-combobox.vue'
|
|
2
|
+
export { default as FieldCombobox } from './field-combobox-select.vue'
|
|
3
|
+
export { default as FieldInputFile } from './field-input-file.vue'
|
|
3
4
|
export { default as FieldInput } from './field-input.vue'
|
|
4
5
|
export { default as FieldKeyValues } from './field-key-values.vue'
|
|
5
6
|
export { default as FieldRange } from './field-range.vue'
|
|
7
|
+
export { default as FieldSelect } from './field-select.vue'
|
|
6
8
|
export { default as FieldTextArea } from './field-text-area.vue'
|
|
7
9
|
export { default as FieldValues } from './field-values.vue'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as BasicInputFile } from './basic-input-file.vue'
|
|
2
|
+
export { default as InputFileCard } from './input-file-card.vue'
|
|
2
3
|
export { default as InputFile } from './input-file.vue'
|
|
3
4
|
export { default as InputKeyValue } from './input-key-value.vue'
|
|
4
5
|
export { default as Input } from './input.vue'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useSlots } from 'vue'
|
|
3
|
+
|
|
4
|
+
import BasicInputFile from './basic-input-file.vue'
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
accept?: string
|
|
8
|
+
multiple?: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const slots = useSlots()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<BasicInputFile
|
|
16
|
+
:class="[
|
|
17
|
+
'min-h-[120px] flex flex-col cursor-pointer items-center justify-center rounded-xl p-6',
|
|
18
|
+
'border-dashed border-2',
|
|
19
|
+
'transition-all duration-300',
|
|
20
|
+
'opacity-95',
|
|
21
|
+
'hover:scale-100 hover:opacity-100 hover:shadow-md hover:dark:shadow-lg',
|
|
22
|
+
]"
|
|
23
|
+
:is-not-dragging-classes="[
|
|
24
|
+
'border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-700',
|
|
25
|
+
'bg-white/60 dark:bg-black/30 hover:bg-white/80 dark:hover:bg-black/40',
|
|
26
|
+
]"
|
|
27
|
+
:is-dragging-classes="[
|
|
28
|
+
'border-primary-400 dark:border-primary-600 hover:border-primary-300 dark:hover:border-primary-700',
|
|
29
|
+
'bg-primary-50/5 dark:bg-primary-900/5',
|
|
30
|
+
]"
|
|
31
|
+
:accept="accept"
|
|
32
|
+
:multiple="multiple"
|
|
33
|
+
>
|
|
34
|
+
<template #default="{ isDragging }">
|
|
35
|
+
<slot v-if="slots.default" :is-dragging="isDragging" />
|
|
36
|
+
<div
|
|
37
|
+
v-else
|
|
38
|
+
class="flex flex-col items-center"
|
|
39
|
+
:class="[
|
|
40
|
+
isDragging ? 'text-primary-500 dark:text-primary-400' : 'text-neutral-400 dark:text-neutral-500',
|
|
41
|
+
]"
|
|
42
|
+
>
|
|
43
|
+
<div i-solar:upload-square-line-duotone mb-2 text-5xl />
|
|
44
|
+
<p font-medium text="center lg">
|
|
45
|
+
Upload
|
|
46
|
+
</p>
|
|
47
|
+
<p v-if="isDragging" text="center" text-sm>
|
|
48
|
+
Release to upload
|
|
49
|
+
</p>
|
|
50
|
+
<p v-else text="center" text-sm>
|
|
51
|
+
Click or drag and drop a file here
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
</BasicInputFile>
|
|
56
|
+
</template>
|
|
@@ -1,56 +1,96 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { useObjectUrl } from '@vueuse/core'
|
|
3
|
+
import { computed } from 'vue'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
defineProps<{
|
|
5
|
+
const props = withDefaults(defineProps<{
|
|
7
6
|
accept?: string
|
|
8
7
|
multiple?: boolean
|
|
9
|
-
|
|
8
|
+
placeholder?: string
|
|
9
|
+
}>(), {
|
|
10
|
+
placeholder: 'Choose file',
|
|
11
|
+
multiple: false,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const modelValue = defineModel<File[] | undefined>({ default: undefined })
|
|
15
|
+
|
|
16
|
+
const fileNames = computed(() => {
|
|
17
|
+
const files = modelValue.value ?? []
|
|
18
|
+
if (!files.length)
|
|
19
|
+
return props.placeholder
|
|
20
|
+
return files.map(file => file.name).join(', ')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const previewImageFile = computed(() => {
|
|
24
|
+
const files = modelValue.value ?? []
|
|
25
|
+
return files.find(file => file.type.startsWith('image/'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const previewUrl = useObjectUrl(previewImageFile)
|
|
29
|
+
|
|
30
|
+
function onFileChange(event: Event) {
|
|
31
|
+
const input = event.target as HTMLInputElement
|
|
32
|
+
if (!input.files) {
|
|
33
|
+
modelValue.value = undefined
|
|
34
|
+
return
|
|
35
|
+
}
|
|
10
36
|
|
|
11
|
-
const
|
|
37
|
+
const files = Array.from(input.files)
|
|
38
|
+
modelValue.value = files.length ? files : undefined
|
|
39
|
+
|
|
40
|
+
// Allow re-selecting the same file.
|
|
41
|
+
input.value = ''
|
|
42
|
+
}
|
|
12
43
|
</script>
|
|
13
44
|
|
|
14
45
|
<template>
|
|
15
|
-
<
|
|
46
|
+
<label
|
|
16
47
|
:class="[
|
|
17
|
-
'
|
|
18
|
-
'border-
|
|
19
|
-
'transition-all duration-
|
|
20
|
-
'
|
|
21
|
-
'hover:
|
|
22
|
-
]"
|
|
23
|
-
:is-not-dragging-classes="[
|
|
24
|
-
'border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-700',
|
|
25
|
-
'bg-white/60 dark:bg-black/30 hover:bg-white/80 dark:hover:bg-black/40',
|
|
26
|
-
]"
|
|
27
|
-
:is-dragging-classes="[
|
|
28
|
-
'border-primary-400 dark:border-primary-600 hover:border-primary-300 dark:hover:border-primary-700',
|
|
29
|
-
'bg-primary-50/5 dark:bg-primary-900/5',
|
|
48
|
+
'w-full flex cursor-pointer items-center gap-2',
|
|
49
|
+
'rounded-lg border-2 border-solid border-neutral-100 bg-neutral-50 px-2 py-1 shadow-sm',
|
|
50
|
+
'transition-all duration-200 ease-in-out',
|
|
51
|
+
'dark:border-neutral-900 dark:bg-neutral-950',
|
|
52
|
+
'hover:border-primary-300/70 dark:hover:border-primary-700/70',
|
|
30
53
|
]"
|
|
31
|
-
:accept="accept"
|
|
32
|
-
:multiple="multiple"
|
|
33
54
|
>
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
<input
|
|
56
|
+
type="file"
|
|
57
|
+
:accept="accept"
|
|
58
|
+
:multiple="multiple"
|
|
59
|
+
:class="[
|
|
60
|
+
'hidden',
|
|
61
|
+
]"
|
|
62
|
+
@change="onFileChange"
|
|
63
|
+
>
|
|
64
|
+
|
|
65
|
+
<div
|
|
66
|
+
:class="[
|
|
67
|
+
'i-solar:upload-square-line-duotone h-5 w-5 shrink-0 text-neutral-500 dark:text-neutral-400',
|
|
68
|
+
]"
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<div
|
|
72
|
+
:class="[
|
|
73
|
+
'min-w-0 flex-1 truncate text-sm text-neutral-600 dark:text-neutral-300',
|
|
74
|
+
]"
|
|
75
|
+
:title="fileNames"
|
|
76
|
+
>
|
|
77
|
+
{{ fileNames }}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div
|
|
81
|
+
v-if="previewUrl"
|
|
82
|
+
:class="[
|
|
83
|
+
'h-8 w-8 shrink-0 overflow-hidden rounded-md border border-neutral-200 bg-white',
|
|
84
|
+
'dark:border-neutral-700 dark:bg-neutral-900',
|
|
85
|
+
]"
|
|
86
|
+
>
|
|
87
|
+
<img
|
|
88
|
+
:src="previewUrl"
|
|
89
|
+
alt="Preview"
|
|
39
90
|
:class="[
|
|
40
|
-
|
|
91
|
+
'h-full w-full object-cover',
|
|
41
92
|
]"
|
|
42
93
|
>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Upload
|
|
46
|
-
</p>
|
|
47
|
-
<p v-if="isDragging" text="center" text-sm>
|
|
48
|
-
Release to upload
|
|
49
|
-
</p>
|
|
50
|
-
<p v-else text="center" text-sm>
|
|
51
|
-
Click or drag and drop a file here
|
|
52
|
-
</p>
|
|
53
|
-
</div>
|
|
54
|
-
</template>
|
|
55
|
-
</BasicInputFile>
|
|
94
|
+
</div>
|
|
95
|
+
</label>
|
|
56
96
|
</template>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script
|
|
2
2
|
setup
|
|
3
3
|
lang="ts"
|
|
4
|
-
generic="InputType extends 'number' | string, T = InputType extends 'number' ? (number | undefined) : ((string | undefined))"
|
|
4
|
+
generic="InputType extends 'number' | InputTypeHTMLAttribute | string, T = InputType extends 'number' ? (number | undefined) : ((string | undefined))"
|
|
5
5
|
>
|
|
6
|
+
import type { InputTypeHTMLAttribute } from 'vue'
|
|
7
|
+
|
|
6
8
|
// Define button variants for better type safety and maintainability
|
|
7
9
|
type InputVariant = 'primary' | 'secondary' | 'primary-dimmed'
|
|
8
10
|
|
|
@@ -71,7 +71,6 @@ https://toughengineer.github.io/demo/slider-styler*/
|
|
|
71
71
|
min-height: var(--height);
|
|
72
72
|
appearance: none;
|
|
73
73
|
background: transparent;
|
|
74
|
-
border-radius: 4px;
|
|
75
74
|
transition: background-color 0.2s ease;
|
|
76
75
|
|
|
77
76
|
--thumb-width: var(--height);
|
|
@@ -84,7 +83,7 @@ https://toughengineer.github.io/demo/slider-styler*/
|
|
|
84
83
|
--track-height: calc(var(--height) - var(--track-value-padding) * 2);
|
|
85
84
|
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
|
|
86
85
|
--track-border: none;
|
|
87
|
-
--track-border-radius:
|
|
86
|
+
--track-border-radius: 16px;
|
|
88
87
|
--track-background: rgba(0, 0, 0, 0.4);
|
|
89
88
|
|
|
90
89
|
--track-value-background: rgb(255, 255, 255);
|
|
@@ -99,7 +98,7 @@ https://toughengineer.github.io/demo/slider-styler*/
|
|
|
99
98
|
--thumb-background: rgb(238, 238, 238);
|
|
100
99
|
|
|
101
100
|
--track-border: none;
|
|
102
|
-
--track-background: rgba(
|
|
101
|
+
--track-background: rgba(64, 64, 64, 0.7);
|
|
103
102
|
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
|
|
104
103
|
|
|
105
104
|
--track-value-background: rgb(238, 238, 238);
|
|
@@ -112,7 +112,7 @@ function toCssSize(value?: string | number): string | undefined {
|
|
|
112
112
|
<SelectTrigger
|
|
113
113
|
:class="[
|
|
114
114
|
'group',
|
|
115
|
-
'w-full inline-flex items-center justify-between border px-
|
|
115
|
+
'w-full inline-flex items-center justify-between border px-3 leading-none h-9 gap-[5px] outline-none',
|
|
116
116
|
props.shape === 'rounded' ? 'rounded-full' : 'rounded-lg',
|
|
117
117
|
'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
|
|
118
118
|
props.variant === 'default' ? 'bg-white dark:bg-neutral-900 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
|
|
@@ -144,7 +144,6 @@ function toCssSize(value?: string | number): string | undefined {
|
|
|
144
144
|
<SelectValue
|
|
145
145
|
v-else
|
|
146
146
|
v-model="modelValue"
|
|
147
|
-
:placeholder="props.placeholder"
|
|
148
147
|
/>
|
|
149
148
|
</div>
|
|
150
149
|
<SelectIcon as-child>
|
|
@@ -19,6 +19,7 @@ interface ButtonProps {
|
|
|
19
19
|
loading?: boolean // Loading state
|
|
20
20
|
variant?: ButtonVariant // Button style variant
|
|
21
21
|
size?: ButtonSize // Button size variant
|
|
22
|
+
shape?: 'rounded' | 'pill' | 'square' // Button shape
|
|
22
23
|
theme?: ButtonTheme // Button theme
|
|
23
24
|
block?: boolean // Full width button
|
|
24
25
|
}
|
|
@@ -127,9 +128,21 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
127
128
|
|
|
128
129
|
// Extract size styles for better organization
|
|
129
130
|
const sizeClasses: Record<ButtonSize, string> = {
|
|
130
|
-
sm:
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
sm: props.shape === 'pill'
|
|
132
|
+
? 'px-3 py-1.5 text-xs'
|
|
133
|
+
: props.shape === 'square'
|
|
134
|
+
? 'p-2 text-xs'
|
|
135
|
+
: 'px-4 py-2 text-sm',
|
|
136
|
+
md: props.shape === 'pill'
|
|
137
|
+
? 'px-4 py-2 text-sm'
|
|
138
|
+
: props.shape === 'square'
|
|
139
|
+
? 'p-3 text-sm'
|
|
140
|
+
: 'px-5 py-3 text-base',
|
|
141
|
+
lg: props.shape === 'pill'
|
|
142
|
+
? 'px-6 py-3 text-base'
|
|
143
|
+
: props.shape === 'square'
|
|
144
|
+
? 'p-4 text-base'
|
|
145
|
+
: 'px-6 py-3 text-base',
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
// Base classes that are always applied
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { errorCauseFrom, errorMessageFrom, errorNameFrom, errorStackFrom } from '@moeru/std'
|
|
3
|
+
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
|
4
|
+
import { computed, shallowRef } from 'vue'
|
|
5
|
+
|
|
6
|
+
import Button from './button.vue'
|
|
7
|
+
|
|
8
|
+
type HeightPreset = 'sm' | 'md' | 'lg' | 'xl' | 'auto'
|
|
9
|
+
|
|
10
|
+
interface ContainerErrorProps {
|
|
11
|
+
error?: unknown
|
|
12
|
+
message?: string
|
|
13
|
+
stack?: string
|
|
14
|
+
includeStack?: boolean
|
|
15
|
+
showCopyButton?: boolean
|
|
16
|
+
showFeedbackButton?: boolean
|
|
17
|
+
copyButtonLabel?: string
|
|
18
|
+
copiedButtonLabel?: string
|
|
19
|
+
feedbackButtonLabel?: string
|
|
20
|
+
heightPreset?: HeightPreset
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(defineProps<ContainerErrorProps>(), {
|
|
24
|
+
includeStack: true,
|
|
25
|
+
showCopyButton: true,
|
|
26
|
+
showFeedbackButton: true,
|
|
27
|
+
copyButtonLabel: 'Copy',
|
|
28
|
+
copiedButtonLabel: 'Copied',
|
|
29
|
+
feedbackButtonLabel: 'Feedback',
|
|
30
|
+
heightPreset: 'md',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
(e: 'copy', content: string): void
|
|
35
|
+
(e: 'feedback'): void
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
const copied = shallowRef(false)
|
|
39
|
+
|
|
40
|
+
const heightPresetClasses: Record<HeightPreset, string[]> = {
|
|
41
|
+
sm: ['h-32'],
|
|
42
|
+
md: ['h-36'],
|
|
43
|
+
lg: ['h-42'],
|
|
44
|
+
xl: ['h-48'],
|
|
45
|
+
auto: [],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function indentLines(content: string, indent = 2): string {
|
|
49
|
+
const spaces = ' '.repeat(indent)
|
|
50
|
+
return content
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map(line => `${spaces}${line}`)
|
|
53
|
+
.join('\n')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resolvedErrorName = computed(() => {
|
|
57
|
+
return (errorNameFrom(props.error) ?? '').trim()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const resolvedMessage = computed(() => {
|
|
61
|
+
const fromProp = props.message?.trim()
|
|
62
|
+
if (fromProp)
|
|
63
|
+
return fromProp
|
|
64
|
+
|
|
65
|
+
const normalizedMessage = errorMessageFrom(props.error)
|
|
66
|
+
if (normalizedMessage)
|
|
67
|
+
return normalizedMessage.trim()
|
|
68
|
+
|
|
69
|
+
return props.error == null ? '' : String(props.error).trim()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const resolvedStack = computed(() => {
|
|
73
|
+
const fromProp = props.stack?.trim()
|
|
74
|
+
if (fromProp) {
|
|
75
|
+
const index = fromProp.indexOf(resolvedMessage.value)
|
|
76
|
+
if (index >= 0)
|
|
77
|
+
return fromProp.slice(index + resolvedMessage.value.length).trim()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!props.includeStack)
|
|
81
|
+
return ''
|
|
82
|
+
|
|
83
|
+
const resolved = (errorStackFrom(props.error) ?? '').trim()
|
|
84
|
+
const index = resolved.indexOf(resolvedMessage.value)
|
|
85
|
+
if (index >= 0)
|
|
86
|
+
return resolved.slice(index + resolvedMessage.value.length).trim()
|
|
87
|
+
|
|
88
|
+
return resolved
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const resolvedCause = computed(() => {
|
|
92
|
+
if (props.error == null)
|
|
93
|
+
return ''
|
|
94
|
+
|
|
95
|
+
const cause = errorCauseFrom(props.error)
|
|
96
|
+
if (cause == null)
|
|
97
|
+
return ''
|
|
98
|
+
|
|
99
|
+
if (cause instanceof Error) {
|
|
100
|
+
const name = errorNameFrom(cause) ?? cause.name ?? 'Error'
|
|
101
|
+
const message = (errorMessageFrom(cause) ?? cause.message ?? '').trim()
|
|
102
|
+
const stack = (errorStackFrom(cause) ?? cause.stack ?? '').trim()
|
|
103
|
+
|
|
104
|
+
const header = message ? `${name}: ${message}` : name
|
|
105
|
+
if (!stack)
|
|
106
|
+
return header
|
|
107
|
+
|
|
108
|
+
return `${header}\n${indentLines(stack, 2)}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return String(cause).trim()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const panelContent = computed(() => {
|
|
115
|
+
const sections: string[] = []
|
|
116
|
+
|
|
117
|
+
if (resolvedErrorName.value || resolvedMessage.value) {
|
|
118
|
+
const header = resolvedErrorName.value
|
|
119
|
+
? (resolvedMessage.value ? `${resolvedErrorName.value}: ${resolvedMessage.value}` : resolvedErrorName.value)
|
|
120
|
+
: resolvedMessage.value
|
|
121
|
+
if (header)
|
|
122
|
+
sections.push(header)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (resolvedStack.value)
|
|
126
|
+
sections.push(`Stack:\n${resolvedStack.value}`)
|
|
127
|
+
|
|
128
|
+
if (resolvedCause.value)
|
|
129
|
+
sections.push(`Cause:\n${resolvedCause.value}`)
|
|
130
|
+
|
|
131
|
+
return sections.join('\n\n')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
async function copyContent() {
|
|
135
|
+
if (!panelContent.value)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
emit('copy', panelContent.value)
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const clipboard = globalThis.navigator?.clipboard
|
|
142
|
+
if (!clipboard)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
await clipboard.writeText(panelContent.value)
|
|
146
|
+
copied.value = true
|
|
147
|
+
globalThis.setTimeout(() => {
|
|
148
|
+
copied.value = false
|
|
149
|
+
}, 1200)
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Ignore clipboard failures and still keep emitted copy payload.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<template>
|
|
158
|
+
<div
|
|
159
|
+
:class="[
|
|
160
|
+
'relative w-full rounded-xl border-2 border-red-200/70 bg-red-50/60 backdrop-blur-md p-1',
|
|
161
|
+
'dark:border-red-900/50 dark:bg-red-950/25',
|
|
162
|
+
]"
|
|
163
|
+
>
|
|
164
|
+
<div :class="['absolute right-4 -translate-x-full top-2 z-10']">
|
|
165
|
+
<Button
|
|
166
|
+
v-if="showCopyButton"
|
|
167
|
+
size="sm"
|
|
168
|
+
variant="secondary"
|
|
169
|
+
shape="square"
|
|
170
|
+
icon="i-solar:copy-line-duotone"
|
|
171
|
+
:title="copied ? copiedButtonLabel : copyButtonLabel"
|
|
172
|
+
:aria-label="copied ? copiedButtonLabel : copyButtonLabel"
|
|
173
|
+
@click="copyContent"
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div :class="['absolute right-2 top-2 z-10']">
|
|
178
|
+
<Button
|
|
179
|
+
v-if="showFeedbackButton"
|
|
180
|
+
size="sm"
|
|
181
|
+
variant="secondary"
|
|
182
|
+
shape="square"
|
|
183
|
+
icon="i-solar:square-share-line-line-duotone"
|
|
184
|
+
:title="feedbackButtonLabel"
|
|
185
|
+
:aria-label="feedbackButtonLabel"
|
|
186
|
+
@click="emit('feedback')"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<ScrollAreaRoot
|
|
191
|
+
type="auto"
|
|
192
|
+
:class="[
|
|
193
|
+
'relative w-full overflow-hidden rounded-xl',
|
|
194
|
+
'bg-neutral-50/80 dark:bg-neutral-950/80',
|
|
195
|
+
...heightPresetClasses[heightPreset],
|
|
196
|
+
]"
|
|
197
|
+
>
|
|
198
|
+
<ScrollAreaViewport :class="['h-full w-full']">
|
|
199
|
+
<div :class="['flex flex-col gap-2 p-3 text-xs']">
|
|
200
|
+
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono font-semibold text-red-700 leading-relaxed dark:text-red-300']">
|
|
201
|
+
{{ resolvedErrorName || 'Error' }}
|
|
202
|
+
<span v-if="resolvedMessage">
|
|
203
|
+
: {{ resolvedMessage }}
|
|
204
|
+
</span>
|
|
205
|
+
</div>
|
|
206
|
+
<pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
|
|
207
|
+
<pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
|
|
208
|
+
<div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
|
|
209
|
+
No error details available.
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</ScrollAreaViewport>
|
|
213
|
+
<ScrollAreaScrollbar orientation="vertical" :class="['w-2 p-0.5']">
|
|
214
|
+
<ScrollAreaThumb :class="['rounded-full bg-neutral-300/80 dark:bg-neutral-700/80']" />
|
|
215
|
+
</ScrollAreaScrollbar>
|
|
216
|
+
<ScrollAreaCorner />
|
|
217
|
+
</ScrollAreaRoot>
|
|
218
|
+
</div>
|
|
219
|
+
</template>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as Button } from './button.vue'
|
|
2
2
|
export { default as Callout } from './callout.vue'
|
|
3
|
+
export { default as ContainerError } from './container-error.vue'
|
|
3
4
|
export { default as DoubleCheckButton } from './double-check-button.vue'
|
|
4
5
|
export { default as Progress } from './progress.vue'
|
|
File without changes
|