@proj-airi/ui 0.9.0 → 0.10.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 +3 -3
- package/src/components/form/field/field-input.vue +18 -1
- package/src/components/form/input/input.vue +8 -0
- package/src/components/form/select-tab/select-tab.vue +22 -8
- package/src/components/form/textarea/basic-text-area.vue +6 -1
- package/src/components/layouts/index.ts +1 -0
- package/src/components/layouts/truncatable.vue +178 -0
- package/src/components/misc/button.vue +10 -8
- package/src/components/misc/container-error.vue +3 -7
- package/src/components/misc/error-boundary.vue +117 -0
- package/src/components/misc/index.ts +1 -0
- package/src/composables/use-theme.ts +5 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/shim/index.ts +1 -0
- package/src/utils/shim/local-storage.ts +27 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proj-airi/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.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",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"@moeru/std": "0.1.0-beta.17",
|
|
27
27
|
"@vueuse/core": "^14.2.1",
|
|
28
28
|
"floating-vue": "^5.2.2",
|
|
29
|
-
"reka-ui": "^2.9.
|
|
30
|
-
"vue": "^3.5.
|
|
29
|
+
"reka-ui": "^2.9.6",
|
|
30
|
+
"vue": "^3.5.32"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@vue-macros/volar": "3.0.0-beta.8",
|
|
@@ -11,7 +11,21 @@ const props = withDefaults(defineProps<{
|
|
|
11
11
|
label?: string
|
|
12
12
|
description?: string
|
|
13
13
|
placeholder?: string
|
|
14
|
+
/**
|
|
15
|
+
* Marks the field as required: enables native HTML5 `required` validation
|
|
16
|
+
* on the underlying input and (by default) renders a `*` next to the label.
|
|
17
|
+
* Use `hideRequiredMark` when the form already conveys required-ness
|
|
18
|
+
* through other means (e.g. all fields are required).
|
|
19
|
+
*/
|
|
14
20
|
required?: boolean
|
|
21
|
+
/**
|
|
22
|
+
* Suppress the `*` indicator next to the label without disabling the
|
|
23
|
+
* underlying HTML5 `required` validation. Useful for forms where every
|
|
24
|
+
* field is required so the marker would just add noise.
|
|
25
|
+
*
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
hideRequiredMark?: boolean
|
|
15
29
|
type?: InputType
|
|
16
30
|
inputClass?: string
|
|
17
31
|
singleLine?: boolean
|
|
@@ -30,7 +44,7 @@ const modelValue = defineModel<T>({ required: false })
|
|
|
30
44
|
<slot name="label">
|
|
31
45
|
{{ props.label }}
|
|
32
46
|
</slot>
|
|
33
|
-
<span v-if="props.required" class="text-red-500">*</span>
|
|
47
|
+
<span v-if="props.required && !props.hideRequiredMark" class="text-red-500">*</span>
|
|
34
48
|
</div>
|
|
35
49
|
<div class="text-xs text-neutral-500 dark:text-neutral-400" text-wrap>
|
|
36
50
|
<slot name="description">
|
|
@@ -43,6 +57,7 @@ const modelValue = defineModel<T>({ required: false })
|
|
|
43
57
|
v-model.number="modelValue"
|
|
44
58
|
:type="props.type"
|
|
45
59
|
:placeholder="props.placeholder"
|
|
60
|
+
:required="props.required"
|
|
46
61
|
:class="props.inputClass"
|
|
47
62
|
/>
|
|
48
63
|
<Input
|
|
@@ -50,6 +65,7 @@ const modelValue = defineModel<T>({ required: false })
|
|
|
50
65
|
v-model="modelValue"
|
|
51
66
|
:type="props.type"
|
|
52
67
|
:placeholder="props.placeholder"
|
|
68
|
+
:required="props.required"
|
|
53
69
|
:class="props.inputClass"
|
|
54
70
|
/>
|
|
55
71
|
<textarea
|
|
@@ -57,6 +73,7 @@ const modelValue = defineModel<T>({ required: false })
|
|
|
57
73
|
v-model="modelValue as string | undefined"
|
|
58
74
|
:type="props.type"
|
|
59
75
|
:placeholder="props.placeholder"
|
|
76
|
+
:required="props.required"
|
|
60
77
|
:class="[
|
|
61
78
|
props.inputClass,
|
|
62
79
|
'focus:primary-300 dark:focus:primary-400/50 border-2 border-solid border-neutral-100 dark:border-neutral-900',
|
|
@@ -18,6 +18,12 @@ const props = withDefaults(defineProps<{
|
|
|
18
18
|
variant?: InputVariant // Button style variant
|
|
19
19
|
size?: InputSize // Button size variant
|
|
20
20
|
theme?: InputTheme // Button theme
|
|
21
|
+
/**
|
|
22
|
+
* Forwarded to the underlying `<input>` element so the browser participates
|
|
23
|
+
* in form validation (HTML5 `:invalid` styling and submit blocking) without
|
|
24
|
+
* the consumer having to drop down to raw HTML.
|
|
25
|
+
*/
|
|
26
|
+
required?: boolean
|
|
21
27
|
}>(), {
|
|
22
28
|
variant: 'primary',
|
|
23
29
|
size: 'md',
|
|
@@ -69,6 +75,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
|
|
|
69
75
|
<input
|
|
70
76
|
v-model.number="modelValue"
|
|
71
77
|
:type="props.type || 'text'"
|
|
78
|
+
:required="props.required"
|
|
72
79
|
:class="[
|
|
73
80
|
'transition-all duration-200 ease-in-out',
|
|
74
81
|
'cursor-disabled:not-allowed',
|
|
@@ -80,6 +87,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
|
|
|
80
87
|
<input
|
|
81
88
|
v-model="modelValue"
|
|
82
89
|
:type="props.type || 'text'"
|
|
90
|
+
:required="props.required"
|
|
83
91
|
:class="[
|
|
84
92
|
'transition-all duration-200 ease-in-out',
|
|
85
93
|
'cursor-disabled:not-allowed',
|
|
@@ -13,11 +13,13 @@ const props = withDefaults(defineProps<{
|
|
|
13
13
|
options: SelectTabOption[]
|
|
14
14
|
disabled?: boolean
|
|
15
15
|
readonly?: boolean
|
|
16
|
-
size?: 'sm' | 'md'
|
|
16
|
+
size?: 'xs' | 'sm' | 'md' | 'w-xs' | 'w-sm' | 'w-md'
|
|
17
|
+
tabSpace?: 'compact' | 'spaced'
|
|
17
18
|
}>(), {
|
|
18
19
|
disabled: false,
|
|
19
20
|
readonly: false,
|
|
20
21
|
size: 'md',
|
|
22
|
+
tabSpace: 'spaced',
|
|
21
23
|
})
|
|
22
24
|
|
|
23
25
|
const modelValue = defineModel<T>({ required: true })
|
|
@@ -27,9 +29,17 @@ const itemCount = computed(() => props.options.length || 1)
|
|
|
27
29
|
const isDisabled = computed(() => props.disabled || props.readonly)
|
|
28
30
|
|
|
29
31
|
const sizeClasses = computed(() =>
|
|
30
|
-
props.size === '
|
|
31
|
-
?
|
|
32
|
-
|
|
32
|
+
props.size === 'xs'
|
|
33
|
+
? props.tabSpace === 'compact'
|
|
34
|
+
? ['py-1', 'px-2', 'text-xs', 'rounded-md']
|
|
35
|
+
: ['py-1', 'px-2', 'text-xs', 'rounded-md', 'min-w-20']
|
|
36
|
+
: props.size === 'sm'
|
|
37
|
+
? props.tabSpace === 'compact'
|
|
38
|
+
? ['py-2', 'px-3', 'text-xs', 'rounded-md']
|
|
39
|
+
: ['py-2', 'px-3', 'text-xs', 'rounded-md', 'min-w-24']
|
|
40
|
+
: props.tabSpace === 'compact'
|
|
41
|
+
? ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md']
|
|
42
|
+
: ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md', 'min-w-32'],
|
|
33
43
|
)
|
|
34
44
|
|
|
35
45
|
const rootStyle = computed(() => ({
|
|
@@ -51,9 +61,11 @@ const rootStyle = computed(() => ({
|
|
|
51
61
|
'is-interacting',
|
|
52
62
|
'relative', 'flex', 'items-stretch', 'rounded-lg',
|
|
53
63
|
'overflow-hidden',
|
|
54
|
-
'bg-
|
|
55
|
-
'transition-[border-color,box-shadow,opacity]
|
|
56
|
-
isDisabled
|
|
64
|
+
'bg-neutral-400/6 dark:bg-neutral-950/70',
|
|
65
|
+
'transition-[border-color,box-shadow,opacity] duration-200 ease-out',
|
|
66
|
+
isDisabled
|
|
67
|
+
? ['cursor-not-allowed', 'opacity-60']
|
|
68
|
+
: ['shadow-[0_14px_50px_-32px_rgba(0,0,0,0.55)]', 'backdrop-blur-sm'],
|
|
57
69
|
// before
|
|
58
70
|
'before:bg-primary-300/50', 'dark:before:bg-primary-400/50',
|
|
59
71
|
'before:rounded-md', 'sm:before:rounded-lg',
|
|
@@ -82,7 +94,9 @@ const rootStyle = computed(() => ({
|
|
|
82
94
|
'transition-[color,background-color,border-color,transform]', 'duration-200', 'ease-out',
|
|
83
95
|
'focus-visible:border-none', 'focus-visible:outline-none',
|
|
84
96
|
sizeClasses,
|
|
85
|
-
isDisabled
|
|
97
|
+
isDisabled
|
|
98
|
+
? 'pointer-events-none'
|
|
99
|
+
: 'cursor-pointer',
|
|
86
100
|
// checked
|
|
87
101
|
'data-[state=checked]:text-primary-950', 'dark:data-[state=checked]:text-primary-50',
|
|
88
102
|
// unchecked
|
|
@@ -53,7 +53,12 @@ watch(input, () => {
|
|
|
53
53
|
return
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// NOTICE: not sure why 4px is required but if not added, when
|
|
57
|
+
// input happened and placeholder now disappeared, the textarea will shrink
|
|
58
|
+
// a little bit and cause the input box to shake.
|
|
59
|
+
// TODO: find out the root cause and remove this magic number, or at least
|
|
60
|
+
// reference a more specific source.
|
|
61
|
+
textareaHeight.value = `${textareaRef.value.scrollHeight + 4}px`
|
|
57
62
|
})
|
|
58
63
|
}, { immediate: true })
|
|
59
64
|
</script>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Amazing work by Derek Morash on CSS line-clamp animation.
|
|
4
|
+
*
|
|
5
|
+
* https://derekmorash.com/writing/css-line-clamp-animation/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useResizeObserver } from '@vueuse/core'
|
|
9
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
|
|
10
|
+
|
|
11
|
+
defineOptions({
|
|
12
|
+
name: 'Truncatable',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<TruncatableProps>(), {
|
|
16
|
+
lineClamp: 3,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Props for a text/content container that can be line-clamped and expanded.
|
|
21
|
+
*/
|
|
22
|
+
interface TruncatableProps {
|
|
23
|
+
/**
|
|
24
|
+
* Maximum visible lines while collapsed.
|
|
25
|
+
*
|
|
26
|
+
* @default 3
|
|
27
|
+
*/
|
|
28
|
+
lineClamp?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const contentRef = useTemplateRef<HTMLElement>('content')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Matches the CSS max-height transition so closing can finish before line-clamp
|
|
35
|
+
* is restored to the inner content.
|
|
36
|
+
*/
|
|
37
|
+
const transitionDurationMs = 300
|
|
38
|
+
|
|
39
|
+
const expanded = shallowRef(false)
|
|
40
|
+
const lineClamped = shallowRef(true)
|
|
41
|
+
const closedHeight = shallowRef(0)
|
|
42
|
+
const openedHeight = shallowRef(0)
|
|
43
|
+
const isOverflowing = shallowRef(false)
|
|
44
|
+
const closeClampTimer = shallowRef<number>()
|
|
45
|
+
|
|
46
|
+
const normalizedLineClamp = computed(() => Math.max(1, Math.floor(props.lineClamp)))
|
|
47
|
+
const visibleHeight = computed(() => expanded.value ? openedHeight.value : closedHeight.value)
|
|
48
|
+
const contentStyle = computed(() => ({
|
|
49
|
+
'--truncatable-line-clamp': String(normalizedLineClamp.value),
|
|
50
|
+
'--truncatable-transition-duration': `${transitionDurationMs}ms`,
|
|
51
|
+
'maxHeight': visibleHeight.value > 0 ? `${visibleHeight.value}px` : undefined,
|
|
52
|
+
}))
|
|
53
|
+
const containerRole = computed(() => isOverflowing.value ? 'button' : undefined)
|
|
54
|
+
const containerTabindex = computed(() => isOverflowing.value ? 0 : undefined)
|
|
55
|
+
|
|
56
|
+
function measureClampedHeight(element: HTMLElement) {
|
|
57
|
+
const previousDisplay = element.style.display
|
|
58
|
+
const previousOverflow = element.style.overflow
|
|
59
|
+
const previousWebkitBoxOrient = element.style.webkitBoxOrient
|
|
60
|
+
const previousWebkitLineClamp = element.style.webkitLineClamp
|
|
61
|
+
|
|
62
|
+
// DOM measurement needs the real rendered width, so temporarily apply the
|
|
63
|
+
// collapsed CSS to the visible content and restore the previous inline styles.
|
|
64
|
+
element.style.display = '-webkit-box'
|
|
65
|
+
element.style.overflow = 'hidden'
|
|
66
|
+
element.style.webkitBoxOrient = 'vertical'
|
|
67
|
+
element.style.webkitLineClamp = String(normalizedLineClamp.value)
|
|
68
|
+
|
|
69
|
+
const height = element.getBoundingClientRect().height
|
|
70
|
+
|
|
71
|
+
element.style.display = previousDisplay
|
|
72
|
+
element.style.overflow = previousOverflow
|
|
73
|
+
element.style.webkitBoxOrient = previousWebkitBoxOrient
|
|
74
|
+
element.style.webkitLineClamp = previousWebkitLineClamp
|
|
75
|
+
|
|
76
|
+
return height
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function measureHeights() {
|
|
80
|
+
await nextTick()
|
|
81
|
+
|
|
82
|
+
const element = contentRef.value
|
|
83
|
+
if (!element)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
const nextClosedHeight = measureClampedHeight(element)
|
|
87
|
+
const nextOpenedHeight = element.scrollHeight
|
|
88
|
+
|
|
89
|
+
closedHeight.value = nextClosedHeight
|
|
90
|
+
openedHeight.value = nextOpenedHeight
|
|
91
|
+
isOverflowing.value = nextOpenedHeight > nextClosedHeight + 1
|
|
92
|
+
|
|
93
|
+
if (!isOverflowing.value) {
|
|
94
|
+
expanded.value = false
|
|
95
|
+
lineClamped.value = true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toggleExpanded() {
|
|
100
|
+
if (!isOverflowing.value)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if (closeClampTimer.value != null)
|
|
104
|
+
window.clearTimeout(closeClampTimer.value)
|
|
105
|
+
|
|
106
|
+
if (expanded.value) {
|
|
107
|
+
expanded.value = false
|
|
108
|
+
closeClampTimer.value = window.setTimeout(() => {
|
|
109
|
+
lineClamped.value = true
|
|
110
|
+
closeClampTimer.value = undefined
|
|
111
|
+
}, transitionDurationMs)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
lineClamped.value = false
|
|
116
|
+
expanded.value = !expanded.value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleContainerKeydown(event: KeyboardEvent) {
|
|
120
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
event.preventDefault()
|
|
124
|
+
toggleExpanded()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onMounted(measureHeights)
|
|
128
|
+
onBeforeUnmount(() => {
|
|
129
|
+
if (closeClampTimer.value != null)
|
|
130
|
+
window.clearTimeout(closeClampTimer.value)
|
|
131
|
+
})
|
|
132
|
+
useResizeObserver(contentRef, measureHeights)
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<template>
|
|
136
|
+
<div
|
|
137
|
+
class="truncatable"
|
|
138
|
+
:class="{ 'truncatable--interactive': isOverflowing }"
|
|
139
|
+
:style="contentStyle"
|
|
140
|
+
:role="containerRole"
|
|
141
|
+
:tabindex="containerTabindex"
|
|
142
|
+
:aria-expanded="isOverflowing ? expanded : undefined"
|
|
143
|
+
@click="toggleExpanded"
|
|
144
|
+
@keydown="handleContainerKeydown"
|
|
145
|
+
>
|
|
146
|
+
<div
|
|
147
|
+
ref="content"
|
|
148
|
+
class="truncatable__inner"
|
|
149
|
+
:class="{ 'truncatable__inner--line-clamped': lineClamped }"
|
|
150
|
+
>
|
|
151
|
+
<slot />
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
155
|
+
|
|
156
|
+
<style scoped>
|
|
157
|
+
.truncatable {
|
|
158
|
+
width: 100%;
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
transition: max-height var(--truncatable-transition-duration) ease;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.truncatable--interactive {
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.truncatable:focus-visible {
|
|
168
|
+
outline: 2px solid currentcolor;
|
|
169
|
+
outline-offset: 2px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.truncatable__inner--line-clamped {
|
|
173
|
+
display: -webkit-box;
|
|
174
|
+
overflow: hidden;
|
|
175
|
+
-webkit-box-orient: vertical;
|
|
176
|
+
-webkit-line-clamp: var(--truncatable-line-clamp);
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
@@ -49,7 +49,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
49
49
|
'rounded-lg',
|
|
50
50
|
'backdrop-blur-md',
|
|
51
51
|
'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30',
|
|
52
|
-
'focus:ring-
|
|
52
|
+
'focus:ring-none',
|
|
53
53
|
'border-2 border-solid border-primary-500/5 dark:border-primary-900/40',
|
|
54
54
|
'text-primary-950 dark:text-primary-100',
|
|
55
55
|
'focus:ring-2',
|
|
@@ -62,7 +62,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
62
62
|
'rounded-lg',
|
|
63
63
|
'backdrop-blur-md',
|
|
64
64
|
'bg-neutral-100/55 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60',
|
|
65
|
-
'focus:ring-
|
|
65
|
+
'focus:ring-none',
|
|
66
66
|
'border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30',
|
|
67
67
|
'text-neutral-950 dark:text-neutral-100',
|
|
68
68
|
'focus:ring-2',
|
|
@@ -76,7 +76,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
76
76
|
'backdrop-blur-md',
|
|
77
77
|
'hover:bg-neutral-50/50 active:bg-neutral-50/90 hover:dark:bg-neutral-800/50 active:dark:bg-neutral-800/90',
|
|
78
78
|
'border-2 border-solid border-neutral-100/60 dark:border-neutral-800/30',
|
|
79
|
-
'focus:ring-
|
|
79
|
+
'focus:ring-none',
|
|
80
80
|
],
|
|
81
81
|
nonToggled: 'bg-neutral-50/70 dark:bg-neutral-800/70 text-neutral-500 dark:text-neutral-400',
|
|
82
82
|
toggled: 'bg-white/90 dark:bg-neutral-500/70 ring-neutral-300/30 dark:ring-neutral-600/60 ring-2 dark:ring-neutral-600/30 text-primary-500 dark:text-primary-100',
|
|
@@ -88,7 +88,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
88
88
|
'rounded-lg',
|
|
89
89
|
'backdrop-blur-md',
|
|
90
90
|
'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30',
|
|
91
|
-
'focus:ring-
|
|
91
|
+
'focus:ring-none',
|
|
92
92
|
'border-2 border-solid border-red-200/30 dark:border-red-900/30',
|
|
93
93
|
'text-red-950 dark:text-red-100',
|
|
94
94
|
],
|
|
@@ -99,9 +99,9 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
99
99
|
default: [
|
|
100
100
|
'rounded-lg',
|
|
101
101
|
'backdrop-blur-md',
|
|
102
|
-
'bg-amber-400/20 hover:bg-amber-400/25 active:bg-amber-400/35 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:active:bg-amber-500/
|
|
103
|
-
'focus:ring-
|
|
104
|
-
'border-2 border-solid border-amber-300/
|
|
102
|
+
'bg-amber-400/20 hover:bg-amber-400/25 active:bg-amber-400/35 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:active:bg-amber-500/20',
|
|
103
|
+
'focus:ring-none',
|
|
104
|
+
'border-2 border-solid border-amber-300/30 dark:border-amber-500/15',
|
|
105
105
|
'text-amber-900 dark:text-amber-50',
|
|
106
106
|
],
|
|
107
107
|
},
|
|
@@ -109,6 +109,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
109
109
|
'pure': {
|
|
110
110
|
default: {
|
|
111
111
|
default: [
|
|
112
|
+
'rounded-lg',
|
|
112
113
|
'bg-transparent',
|
|
113
114
|
'text-neutral-900 dark:text-neutral-50',
|
|
114
115
|
'!px-0 !py-0',
|
|
@@ -118,10 +119,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
|
|
|
118
119
|
'ghost': {
|
|
119
120
|
default: {
|
|
120
121
|
default: [
|
|
122
|
+
'rounded-lg',
|
|
121
123
|
'bg-transparent',
|
|
122
124
|
'hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50',
|
|
123
125
|
'text-neutral-500 dark:text-neutral-400',
|
|
124
|
-
'focus:ring-
|
|
126
|
+
'focus:ring-none',
|
|
125
127
|
],
|
|
126
128
|
},
|
|
127
129
|
},
|
|
@@ -157,11 +157,10 @@ async function copyContent() {
|
|
|
157
157
|
<template>
|
|
158
158
|
<div
|
|
159
159
|
:class="[
|
|
160
|
-
'relative w-full rounded-
|
|
161
|
-
'dark:border-red-900/50 dark:bg-red-950/25',
|
|
160
|
+
'relative w-full rounded-lg bg-red-50/60 dark:bg-red-950/25 backdrop-blur-md p-1',
|
|
162
161
|
]"
|
|
163
162
|
>
|
|
164
|
-
<div :class="['absolute right-
|
|
163
|
+
<div :class="['absolute right-2 -translate-x-full top-2 z-10']">
|
|
165
164
|
<Button
|
|
166
165
|
v-if="showCopyButton"
|
|
167
166
|
size="sm"
|
|
@@ -172,9 +171,7 @@ async function copyContent() {
|
|
|
172
171
|
:aria-label="copied ? copiedButtonLabel : copyButtonLabel"
|
|
173
172
|
@click="copyContent"
|
|
174
173
|
/>
|
|
175
|
-
</div>
|
|
176
174
|
|
|
177
|
-
<div :class="['absolute right-2 top-2 z-10']">
|
|
178
175
|
<Button
|
|
179
176
|
v-if="showFeedbackButton"
|
|
180
177
|
size="sm"
|
|
@@ -191,13 +188,12 @@ async function copyContent() {
|
|
|
191
188
|
type="auto"
|
|
192
189
|
:class="[
|
|
193
190
|
'relative w-full overflow-hidden rounded-xl',
|
|
194
|
-
'bg-neutral-50/80 dark:bg-neutral-950/80',
|
|
195
191
|
...heightPresetClasses[heightPreset],
|
|
196
192
|
]"
|
|
197
193
|
>
|
|
198
194
|
<ScrollAreaViewport :class="['h-full w-full']">
|
|
199
195
|
<div :class="['flex flex-col gap-2 p-3 text-xs']">
|
|
200
|
-
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono
|
|
196
|
+
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
|
|
201
197
|
{{ resolvedErrorName || 'Error' }}
|
|
202
198
|
<span v-if="resolvedMessage">
|
|
203
199
|
: {{ resolvedMessage }}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onErrorCaptured, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
import ContainerError from './container-error.vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Error boundary that contains exceptions thrown during the render or setup of
|
|
8
|
+
* descendant components and renders a fallback UI instead of letting the error
|
|
9
|
+
* propagate to the host (which would tear the surrounding layout down).
|
|
10
|
+
*
|
|
11
|
+
* Use when:
|
|
12
|
+
* - Wrapping a `<RouterView>` so a single broken route never blanks the whole app shell.
|
|
13
|
+
* - Wrapping any subtree where partial failure is preferable to total failure.
|
|
14
|
+
*
|
|
15
|
+
* Expects:
|
|
16
|
+
* - Children may throw synchronously during render or in `setup`. Async rejections
|
|
17
|
+
* that bubble out of unhandled promises are NOT caught — Vue does not surface those
|
|
18
|
+
* to `onErrorCaptured`. Use `app.config.errorHandler` for those.
|
|
19
|
+
*
|
|
20
|
+
* Returns:
|
|
21
|
+
* - Default slot when there is no captured error.
|
|
22
|
+
* - `fallback` slot (or built-in `ContainerError` UI) when an error has been captured.
|
|
23
|
+
* The boundary suppresses error propagation by returning `false` from
|
|
24
|
+
* `onErrorCaptured`, so the parent stays mounted.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface ErrorBoundaryProps {
|
|
28
|
+
/**
|
|
29
|
+
* Optional title shown above the error details. Useful when the boundary
|
|
30
|
+
* wraps a recognizable region (e.g. "Stage failed to load").
|
|
31
|
+
*/
|
|
32
|
+
title?: string
|
|
33
|
+
/**
|
|
34
|
+
* Whether to show the built-in retry button. Retry remounts the default slot
|
|
35
|
+
* by bumping an internal key, giving the subtree a fresh chance to render.
|
|
36
|
+
* @default true
|
|
37
|
+
*/
|
|
38
|
+
retryable?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Label for the retry button.
|
|
41
|
+
* @default 'Try again'
|
|
42
|
+
*/
|
|
43
|
+
retryLabel?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<ErrorBoundaryProps>(), {
|
|
47
|
+
retryable: true,
|
|
48
|
+
retryLabel: 'Try again',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const emit = defineEmits<{
|
|
52
|
+
(e: 'error', err: unknown, instance: unknown, info: string): void
|
|
53
|
+
(e: 'retry'): void
|
|
54
|
+
}>()
|
|
55
|
+
|
|
56
|
+
const capturedError = ref<unknown>(null)
|
|
57
|
+
const capturedInfo = ref<string>('')
|
|
58
|
+
const renderKey = ref(0)
|
|
59
|
+
|
|
60
|
+
onErrorCaptured((err, instance, info) => {
|
|
61
|
+
capturedError.value = err
|
|
62
|
+
capturedInfo.value = info
|
|
63
|
+
emit('error', err, instance, info)
|
|
64
|
+
// Stop propagation so the host layout keeps rendering.
|
|
65
|
+
return false
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function retry() {
|
|
69
|
+
capturedError.value = null
|
|
70
|
+
capturedInfo.value = ''
|
|
71
|
+
renderKey.value += 1
|
|
72
|
+
emit('retry')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
defineExpose({ retry, hasError: () => capturedError.value != null })
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<template v-if="capturedError == null">
|
|
80
|
+
<slot :key="renderKey" />
|
|
81
|
+
</template>
|
|
82
|
+
<template v-else>
|
|
83
|
+
<slot
|
|
84
|
+
name="fallback"
|
|
85
|
+
:error="capturedError"
|
|
86
|
+
:info="capturedInfo"
|
|
87
|
+
:retry="retry"
|
|
88
|
+
>
|
|
89
|
+
<div :class="['flex flex-col gap-3 p-4 max-w-2xl mx-auto']">
|
|
90
|
+
<div v-if="props.title" :class="['text-base font-semibold text-red-700 dark:text-red-300']">
|
|
91
|
+
{{ props.title }}
|
|
92
|
+
</div>
|
|
93
|
+
<div v-if="capturedInfo" :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
|
94
|
+
During: {{ capturedInfo }}
|
|
95
|
+
</div>
|
|
96
|
+
<ContainerError
|
|
97
|
+
:error="capturedError"
|
|
98
|
+
height-preset="lg"
|
|
99
|
+
/>
|
|
100
|
+
<div v-if="props.retryable" :class="['flex justify-end']">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
:class="[
|
|
104
|
+
'px-3 py-1.5 rounded-lg text-sm font-medium',
|
|
105
|
+
'bg-red-100 hover:bg-red-200 text-red-800',
|
|
106
|
+
'dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200',
|
|
107
|
+
'transition-colors',
|
|
108
|
+
]"
|
|
109
|
+
@click="retry"
|
|
110
|
+
>
|
|
111
|
+
{{ props.retryLabel }}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</slot>
|
|
116
|
+
</template>
|
|
117
|
+
</template>
|
|
@@ -2,4 +2,5 @@ export { default as Button } from './button.vue'
|
|
|
2
2
|
export { default as Callout } from './callout.vue'
|
|
3
3
|
export { default as ContainerError } from './container-error.vue'
|
|
4
4
|
export { default as DoubleCheckButton } from './double-check-button.vue'
|
|
5
|
+
export { default as ErrorBoundary } from './error-boundary.vue'
|
|
5
6
|
export { default as Progress } from './progress.vue'
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { useDark, useToggle } from '@vueuse/core'
|
|
2
2
|
|
|
3
|
+
import { LocalStorageShim } from '../utils'
|
|
4
|
+
|
|
3
5
|
const isDark = useDark({
|
|
4
6
|
disableTransition: true,
|
|
7
|
+
// NOTICE: for histoire, used in packages/stage-ui, localStorage global variable exists but `storage.getItem is not a function` wil
|
|
8
|
+
// thrown, here we added LocalStorageShim to avoid this issue, and it will fallback to real localStorage when it's available.
|
|
9
|
+
storage: 'localStorage' in globalThis && localStorage != null && 'getItem' in localStorage && typeof localStorage.getItem === 'function' ? localStorage : new LocalStorageShim(),
|
|
5
10
|
})
|
|
6
11
|
|
|
7
12
|
const toggleDark = useToggle(isDark)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LocalStorageShim } from './shim'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LocalStorageShim } from './local-storage'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class LocalStorageShim implements Storage {
|
|
2
|
+
private map = new Map<string, any>()
|
|
3
|
+
|
|
4
|
+
clear() {
|
|
5
|
+
this.map.clear()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
getItem(key: string) {
|
|
9
|
+
return this.map.get(key) || null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
key(index: number) {
|
|
13
|
+
return Array.from(this.map.keys())[index] || null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get length() {
|
|
17
|
+
return this.map.size
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setItem(key: string, value: string) {
|
|
21
|
+
this.map.set(key, value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
removeItem(key: string) {
|
|
25
|
+
this.map.delete(key)
|
|
26
|
+
}
|
|
27
|
+
}
|